diff --git a/.coveragerc b/.coveragerc index 70a74e0a356..a5d7eec9115 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,8 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airthings/__init__.py + homeassistant/components/airthings/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/const.py @@ -49,6 +51,7 @@ omit = homeassistant/components/alarmdecoder/sensor.py homeassistant/components/alpha_vantage/sensor.py homeassistant/components/amazon_polly/* + homeassistant/components/amberelectric/__init__.py homeassistant/components/ambiclimate/climate.py homeassistant/components/ambient_station/* homeassistant/components/amcrest/* @@ -171,6 +174,13 @@ omit = homeassistant/components/coolmaster/const.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/cpuspeed/sensor.py + homeassistant/components/crownstone/__init__.py + homeassistant/components/crownstone/const.py + homeassistant/components/crownstone/listeners.py + homeassistant/components/crownstone/helpers.py + homeassistant/components/crownstone/devices.py + homeassistant/components/crownstone/entry_manager.py + homeassistant/components/crownstone/light.py homeassistant/components/cups/sensor.py homeassistant/components/currencylayer/sensor.py homeassistant/components/daikin/* @@ -203,7 +213,6 @@ omit = homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/switch.py - homeassistant/components/dlna_dmr/media_player.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* homeassistant/components/doods/* @@ -368,7 +377,6 @@ omit = homeassistant/components/garages_amsterdam/sensor.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 @@ -687,7 +695,6 @@ omit = homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/light.py - homeassistant/components/nanoleaf/util.py homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py @@ -699,7 +706,10 @@ omit = homeassistant/components/nello/lock.py homeassistant/components/nest/legacy/* homeassistant/components/netdata/sensor.py + homeassistant/components/netgear/__init__.py homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear/router.py + homeassistant/components/netgear/sensor.py homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py @@ -754,6 +764,7 @@ omit = homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py + homeassistant/components/opengarage/__init__.py homeassistant/components/opengarage/cover.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/media_player.py @@ -864,9 +875,6 @@ omit = homeassistant/components/rest/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py - homeassistant/components/rituals_perfume_genie/binary_sensor.py - homeassistant/components/rituals_perfume_genie/number.py - homeassistant/components/rituals_perfume_genie/select.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py @@ -990,7 +998,6 @@ omit = homeassistant/components/squeezebox/__init__.py homeassistant/components/squeezebox/browse_media.py homeassistant/components/squeezebox/media_player.py - homeassistant/components/ssdp/util.py homeassistant/components/starline/* homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py @@ -1001,12 +1008,20 @@ omit = homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py homeassistant/components/surepetcare/__init__.py + homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/binary_sensor.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py + homeassistant/components/switchbot/binary_sensor.py + homeassistant/components/switchbot/__init__.py + homeassistant/components/switchbot/const.py + homeassistant/components/switchbot/entity.py + homeassistant/components/switchbot/cover.py + homeassistant/components/switchbot/sensor.py + homeassistant/components/switchbot/coordinator.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py @@ -1032,6 +1047,8 @@ omit = homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py + homeassistant/components/tautulli/const.py + homeassistant/components/tautulli/coordinator.py homeassistant/components/tautulli/sensor.py homeassistant/components/ted5000/sensor.py homeassistant/components/telegram/notify.py @@ -1047,14 +1064,6 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/tesla/__init__.py - homeassistant/components/tesla/binary_sensor.py - homeassistant/components/tesla/climate.py - homeassistant/components/tesla/const.py - homeassistant/components/tesla/device_tracker.py - homeassistant/components/tesla/lock.py - homeassistant/components/tesla/sensor.py - homeassistant/components/tesla/switch.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* @@ -1088,16 +1097,15 @@ omit = homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/totalconnect/const.py homeassistant/components/touchline/climate.py - homeassistant/components/tplink/common.py - homeassistant/components/tplink/switch.py homeassistant/components/tplink_lte/* homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py - homeassistant/components/trackr/device_tracker.py homeassistant/components/tractive/__init__.py + homeassistant/components/tractive/binary_sensor.py homeassistant/components/tractive/device_tracker.py homeassistant/components/tractive/entity.py homeassistant/components/tractive/sensor.py + homeassistant/components/tractive/switch.py homeassistant/components/tradfri/* homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1107,9 +1115,9 @@ omit = homeassistant/components/transmission/errors.py homeassistant/components/travisci/sensor.py homeassistant/components/tuya/__init__.py + homeassistant/components/tuya/base.py homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py - homeassistant/components/tuya/cover.py homeassistant/components/tuya/fan.py homeassistant/components/tuya/light.py homeassistant/components/tuya/scene.py @@ -1177,6 +1185,8 @@ omit = homeassistant/components/waterfurnace/* homeassistant/components/watson_iot/* homeassistant/components/watson_tts/tts.py + homeassistant/components/watttime/__init__.py + homeassistant/components/watttime/sensor.py homeassistant/components/waze_travel_time/__init__.py homeassistant/components/waze_travel_time/helpers.py homeassistant/components/waze_travel_time/sensor.py @@ -1283,5 +1293,6 @@ exclude_lines = raise AssertionError raise NotImplementedError - # TYPE_CHECKING block is never executed during pytest run + # TYPE_CHECKING and @overload blocks are never executed during pytest run if TYPE_CHECKING: + @overload diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 25d4d0ca8a0..d8558c6fdff 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -133,7 +133,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.07.0 + uses: home-assistant/builder@2021.09.0 with: args: | $BUILD_ARGS \ @@ -186,7 +186,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.07.0 + uses: home-assistant/builder@2021.09.0 with: args: | $BUILD_ARGS \ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d91c18c2e6b..a04e815af7f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ on: pull_request: ~ env: - CACHE_VERSION: 2 + CACHE_VERSION: 3 DEFAULT_PYTHON: 3.8 PRE_COMMIT_CACHE: ~/.cache/pre-commit SQLALCHEMY_WARN_20: 1 @@ -580,7 +580,7 @@ jobs: python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" "setuptools<58" wheel + pip install -U "pip<20.3" setuptools wheel pip install -r requirements_all.txt pip install -r requirements_test.txt pip install -e . @@ -740,4 +740,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2.0.3 + uses: codecov/codecov-action@v2.1.0 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 96fc69e3b68..6be819f9b82 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,12 +9,12 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2.1.2 + - uses: dessant/lock-threads@v3 with: github-token: ${{ github.token }} - issue-lock-inactive-days: "30" - issue-exclude-created-before: "2020-10-01T00:00:00Z" + issue-inactive-days: "30" + exclude-issue-created-before: "2020-10-01T00:00:00Z" issue-lock-reason: "" - pr-lock-inactive-days: "1" - pr-exclude-created-before: "2020-11-01T00:00:00Z" + pr-inactive-days: "1" + exclude-pr-created-before: "2020-11-01T00:00:00Z" pr-lock-reason: "" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 95f7f1fda4d..9cced377f8c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -65,7 +65,6 @@ jobs: matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - - "3.9-alpine3.13" - "3.9-alpine3.14" steps: - name: Checkout the repository @@ -90,7 +89,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev" + apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;cargo" pip: "Cython;numpy" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" @@ -106,7 +105,6 @@ jobs: matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - - "3.9-alpine3.13" - "3.9-alpine3.14" steps: - name: Checkout the repository @@ -160,7 +158,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev" + apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;cargo" pip: "Cython;numpy;scikit-build" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" diff --git a/.gitignore b/.gitignore index bdc4c24c5b0..d6f7198fcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -config/* +/config config2/* tests/testing_config/deps diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38ba2a503af..0a9424e53b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.23.3 + rev: v2.27.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 21.7b0 + rev: 21.9b0 hooks: - id: black args: diff --git a/.strict-typing b/.strict-typing index e0993c2954a..b5be241ac00 100644 --- a/.strict-typing +++ b/.strict-typing @@ -27,9 +27,11 @@ homeassistant.components.calendar.* homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.cover.* +homeassistant.components.crownstone.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* +homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* @@ -54,6 +56,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* homeassistant.components.image_processing.* homeassistant.components.integration.* +homeassistant.components.iqvia.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lcn.* @@ -62,6 +65,7 @@ homeassistant.components.local_ip.* homeassistant.components.lock.* homeassistant.components.mailbox.* homeassistant.components.media_player.* +homeassistant.components.modbus.* homeassistant.components.mysensors.* homeassistant.components.nam.* homeassistant.components.neato.* @@ -85,6 +89,7 @@ homeassistant.components.recorder.statistics homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.rituals_perfume_genie.* +homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.select.* homeassistant.components.sensor.* @@ -95,18 +100,23 @@ homeassistant.components.sonos.media_player homeassistant.components.ssdp.* homeassistant.components.stream.* homeassistant.components.sun.* +homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* +homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.tile.* +homeassistant.components.tplink.* +homeassistant.components.tradfri.* homeassistant.components.tts.* homeassistant.components.upcloud.* homeassistant.components.uptime.* homeassistant.components.uptimerobot.* homeassistant.components.vacuum.* +homeassistant.components.vallox.* homeassistant.components.water_heater.* homeassistant.components.weather.* homeassistant.components.websocket_api.* diff --git a/CODEOWNERS b/CODEOWNERS index a1b12a81127..a43b38e509d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,7 @@ homeassistant/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks +homeassistant/components/airthings/* @danielhiversen homeassistant/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 @@ -36,6 +37,7 @@ homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy homeassistant/components/almond/* @gcampax @balloob homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/ambee/* @frenck +homeassistant/components/amberelectric/* @madpilot homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya homeassistant/components/amcrest/* @flacjacket @@ -73,7 +75,7 @@ homeassistant/components/blink/* @fronzbot homeassistant/components/blueprint/* @home-assistant/core homeassistant/components/bmp280/* @belidzs homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe -homeassistant/components/bond/* @prystupa +homeassistant/components/bond/* @prystupa @joshs85 homeassistant/components/bosch_shc/* @tschamm homeassistant/components/braviatv/* @bieniu @Drafteed homeassistant/components/broadlink/* @danielhiversen @felipediel @@ -104,6 +106,7 @@ homeassistant/components/coronavirus/* @home-assistant/core homeassistant/components/counter/* @fabaff homeassistant/components/cover/* @home-assistant/core homeassistant/components/cpuspeed/* @fabaff +homeassistant/components/crownstone/* @Crownstone @RicArch97 homeassistant/components/cups/* @fabaff homeassistant/components/daikin/* @fredrike homeassistant/components/darksky/* @fabaff @@ -120,6 +123,7 @@ homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey homeassistant/components/digital_ocean/* @fabaff homeassistant/components/discogs/* @thibmaek +homeassistant/components/dlna_dmr/* @StevenLooman @chishm homeassistant/components/doorbird/* @oblogic7 @bdraco homeassistant/components/dsmr/* @Robbie1221 @frenck homeassistant/components/dsmr_reader/* @depl0y @@ -132,6 +136,7 @@ homeassistant/components/ecobee/* @marthoc homeassistant/components/econet/* @vangorra @w1ll1am23 homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/edl21/* @mtdcr +homeassistant/components/efergy/* @tkdrob homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck @@ -202,7 +207,7 @@ homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning @muppet3000 @JasperPlant homeassistant/components/guardian/* @bachya homeassistant/components/habitica/* @ASMfreaK @leikoilja -homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey +homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan homeassistant/components/hassio/* @home-assistant/supervisor homeassistant/components/heatmiser/* @andylockran homeassistant/components/heos/* @andrewsayre @@ -248,7 +253,7 @@ homeassistant/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 -homeassistant/components/iotawatt/* @gtdiehl +homeassistant/components/iotawatt/* @gtdiehl @jyavenard homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington @@ -263,7 +268,7 @@ homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel homeassistant/components/kef/* @basnijholt -homeassistant/components/keyboard_remote/* @bendavid +homeassistant/components/keyboard_remote/* @bendavid @lanrat homeassistant/components/kmtronic/* @dgomes homeassistant/components/knx/* @Julius2342 @farmio @marvin-w homeassistant/components/kodi/* @OnFreund @cgtobi @@ -312,6 +317,7 @@ homeassistant/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik +homeassistant/components/modem_callerid/* @tkdrob homeassistant/components/modern_forms/* @wonderslug homeassistant/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff @@ -335,6 +341,7 @@ homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @allenporter homeassistant/components/netatmo/* @cgtobi homeassistant/components/netdata/* @fabaff +homeassistant/components/netgear/* @hacf-fr @Quentame @starkillerOG homeassistant/components/nexia/* @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys @@ -500,7 +507,7 @@ homeassistant/components/supla/* @mwegrzynek homeassistant/components/surepetcare/* @benleb @danielhiversen homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff -homeassistant/components/switchbot/* @danielhiversen +homeassistant/components/switchbot/* @danielhiversen @RenierM26 homeassistant/components/switcher_kis/* @tomerfi @thecode homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthing/* @zhulik @@ -518,7 +525,6 @@ homeassistant/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne @home-assistant/core -homeassistant/components/tesla/* @zabuldon @alandtse homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/* @fabaff @@ -538,7 +544,7 @@ homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins homeassistant/components/tts/* @pvizeli -homeassistant/components/tuya/* @ollo69 +homeassistant/components/tuya/* @Tuya @zlinoliver @METISU homeassistant/components/twentemilieu/* @frenck homeassistant/components/twinkly/* @dr1rrb homeassistant/components/ubus/* @noltari @@ -553,6 +559,7 @@ homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/usb/* @bdraco homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes +homeassistant/components/vallox/* @andre-richter homeassistant/components/velbus/* @Cereal2nd @brefra homeassistant/components/velux/* @Julius2342 homeassistant/components/vera/* @pavoni @@ -571,10 +578,12 @@ homeassistant/components/wake_on_lan/* @ntilley905 homeassistant/components/wallbox/* @hesselonline homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai +homeassistant/components/watttime/* @bachya homeassistant/components/weather/* @fabaff homeassistant/components/webostv/* @bendavid @thecode homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @esev +homeassistant/components/whirlpool/* @abmantis homeassistant/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj homeassistant/components/wirelesstag/* @sergeymaysak diff --git a/Dockerfile.dev b/Dockerfile.dev index 6dd789761e6..5ebaa644ce5 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,6 +6,8 @@ RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + # Additional library needed by some tests and accordingly by VScode Tests Discovery + bluez \ libudev-dev \ libavformat-dev \ libavcodec-dev \ diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 52ae8eacdd3..071f4d81cdf 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -118,14 +118,6 @@ homeassistant.util.pressure :undoc-members: :show-inheritance: -homeassistant.util.ruamel\_yaml -------------------------------- - -.. automodule:: homeassistant.util.ruamel_yaml - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.ssl ---------------------- diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 177c3a10853..c2802e1b9c4 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import faulthandler import os import platform import subprocess @@ -10,6 +11,8 @@ import threading from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ +FAULT_LOG_FILENAME = "home-assistant.log.fault" + def validate_python() -> None: """Validate that the right Python version is running.""" @@ -132,16 +135,14 @@ def get_arguments() -> argparse.Namespace: def daemonize() -> None: """Move current process to daemon process.""" # Create first fork - pid = os.fork() - if pid > 0: + if os.fork() > 0: sys.exit(0) # Decouple fork os.setsid() # Create second fork - pid = os.fork() - if pid > 0: + if os.fork() > 0: sys.exit(0) # redirect standard file descriptors to devnull @@ -311,7 +312,15 @@ def main() -> int: open_ui=args.open_ui, ) - exit_code = runner.run(runtime_conf) + fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) + with open(fault_file_name, mode="a", encoding="utf8") as fault_file: + faulthandler.enable(fault_file) + exit_code = runner.run(runtime_conf) + faulthandler.disable() + + if os.path.getsize(fault_file_name) == 0: + os.remove(fault_file_name) + if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 519582ea48c..c528aff221f 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -341,8 +341,7 @@ class AuthManager: "System generated users cannot enable multi-factor auth module." ) - module = self.get_auth_mfa_module(mfa_module_id) - if module is None: + if (module := self.get_auth_mfa_module(mfa_module_id)) is None: raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_setup_user(user.id, data) @@ -356,8 +355,7 @@ class AuthManager: "System generated users cannot disable multi-factor auth module." ) - module = self.get_auth_mfa_module(mfa_module_id) - if module is None: + if (module := self.get_auth_mfa_module(mfa_module_id)) is None: raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_depose_user(user.id) @@ -466,7 +464,7 @@ class AuthManager: }, refresh_token.jwt_key, algorithm="HS256", - ).decode() + ) @callback def _async_resolve_provider( @@ -498,8 +496,7 @@ class AuthManager: Will raise InvalidAuthError on errors. """ - provider = self._async_resolve_provider(refresh_token) - if provider: + if provider := self._async_resolve_provider(refresh_token): provider.async_validate_refresh_token(refresh_token, remote_ip) async def async_validate_access_token( @@ -507,7 +504,9 @@ class AuthManager: ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode(token, verify=False) + unverif_claims = jwt.decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 63cbeb1bf7e..c935a0da7d0 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -96,8 +96,7 @@ class AuthStore: groups = [] for group_id in group_ids or []: - group = self._groups.get(group_id) - if group is None: + if (group := self._groups.get(group_id)) is None: raise ValueError(f"Invalid group specified {group_id}") groups.append(group) @@ -160,8 +159,7 @@ class AuthStore: if group_ids is not None: groups = [] for grid in group_ids: - group = self._groups.get(grid) - if group is None: + if (group := self._groups.get(grid)) is None: raise ValueError("Invalid group specified.") groups.append(group) @@ -446,16 +444,14 @@ class AuthStore: ) continue - token_type = rt_dict.get("token_type") - if token_type is None: + if (token_type := rt_dict.get("token_type")) is None: if rt_dict["client_id"] is None: token_type = models.TOKEN_TYPE_SYSTEM else: token_type = models.TOKEN_TYPE_NORMAL # old refresh_token don't have last_used_at (pre-0.78) - last_used_at_str = rt_dict.get("last_used_at") - if last_used_at_str: + if last_used_at_str := rt_dict.get("last_used_at"): last_used_at = dt_util.parse_datetime(last_used_at_str) else: last_used_at = None diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 1d40339417b..a50b762b121 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -38,12 +38,12 @@ class InsecureExampleModule(MultiFactorAuthModule): @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({"pin": str}) + return vol.Schema({vol.Required("pin"): str}) @property def setup_schema(self) -> vol.Schema: """Validate async_setup_user input data.""" - return vol.Schema({"pin": str}) + return vol.Schema({vol.Required("pin"): str}) async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 31210e2d39a..ec5d5b7cd03 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -110,7 +110,7 @@ class NotifyAuthModule(MultiFactorAuthModule): @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({INPUT_FIELD_CODE: str}) + return vol.Schema({vol.Required(INPUT_FIELD_CODE): str}) async def _async_load(self) -> None: """Load stored data.""" @@ -118,9 +118,7 @@ class NotifyAuthModule(MultiFactorAuthModule): if self._user_settings is not None: return - data = await self._user_store.async_load() - - if data is None: + if (data := await self._user_store.async_load()) is None: data = {STORAGE_USERS: {}} self._user_settings = { @@ -207,8 +205,7 @@ class NotifyAuthModule(MultiFactorAuthModule): await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: return False # user_input has been validate in caller @@ -225,8 +222,7 @@ class NotifyAuthModule(MultiFactorAuthModule): await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id) - if notify_setting is None: + if (notify_setting := self._user_settings.get(user_id)) is None: raise ValueError("Cannot find user_id") def generate_secret_and_one_time_password() -> str: diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 20030ae166b..0ff7e1147b1 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -84,7 +84,7 @@ class TotpAuthModule(MultiFactorAuthModule): @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({INPUT_FIELD_CODE: str}) + return vol.Schema({vol.Required(INPUT_FIELD_CODE): str}) async def _async_load(self) -> None: """Load stored data.""" @@ -92,9 +92,7 @@ class TotpAuthModule(MultiFactorAuthModule): if self._users is not None: return - data = await self._user_store.async_load() - - if data is None: + if (data := await self._user_store.async_load()) is None: data = {STORAGE_USERS: {}} self._users = data.get(STORAGE_USERS, {}) @@ -163,8 +161,7 @@ class TotpAuthModule(MultiFactorAuthModule): """Validate two factor authentication code.""" import pyotp # pylint: disable=import-outside-toplevel - ota_secret = self._users.get(user_id) # type: ignore - if ota_secret is None: + if (ota_secret := self._users.get(user_id)) is None: # type: ignore # even we cannot find user, we still do verify # to make timing the same as if user was found. pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 28ff3f638d4..694ea2b7379 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,8 +1,9 @@ """Permissions for Home Assistant.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol @@ -33,9 +34,7 @@ class AbstractPermissions: def check_entity(self, entity_id: str, key: str) -> bool: """Check if we can access entity.""" - entity_func = self._cached_entity_func - - if entity_func is None: + if (entity_func := self._cached_entity_func) is None: entity_func = self._cached_entity_func = self._entity_func() return entity_func(entity_id, key) diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index f19590a6349..3f2a0c14f19 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from typing import Callable +from collections.abc import Callable import voluptuous as vol diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index e95e0080b50..28823a9fd1b 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -72,8 +72,7 @@ def compile_policy( def apply_policy_funcs(object_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(object_id, key) - if result is not None: + if (result := func(object_id, key)) is not None: return result return False diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 4faa277a081..dc5f8f2580c 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -169,9 +169,7 @@ async def load_auth_provider_module( if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module - processed = hass.data.get(DATA_REQS) - - if processed is None: + if (processed := hass.data.get(DATA_REQS)) is None: processed = hass.data[DATA_REQS] = set() elif provider in processed: return module diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 6d1a1627fd5..81a6b6d78e5 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import collections from collections.abc import Mapping import logging import os @@ -148,10 +147,13 @@ class CommandLineLoginFlow(LoginFlow): user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = collections.OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index b08c59bf3aa..1ffed6f87fd 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import base64 -from collections import OrderedDict from collections.abc import Mapping import logging from typing import Any, cast @@ -82,9 +81,7 @@ class Data: async def async_load(self) -> None: """Load stored data.""" - data = await self._store.async_load() - - if data is None: + if (data := await self._store.async_load()) is None: data = {"users": []} seen: set[str] = set() @@ -93,9 +90,7 @@ class Data: username = user["username"] # check if we have duplicates - folded = username.casefold() - - if folded in seen: + if (folded := username.casefold()) in seen: self.is_legacy = True logging.getLogger(__name__).warning( @@ -339,10 +334,13 @@ class HassLoginFlow(LoginFlow): user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index fb390b65b0d..9ad6da27ce3 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -1,7 +1,6 @@ """Example auth provider.""" from __future__ import annotations -from collections import OrderedDict from collections.abc import Mapping import hmac from typing import Any, cast @@ -117,10 +116,13 @@ class ExampleLoginFlow(LoginFlow): user_input.pop("password") return await self.async_finish(user_input) - schema: dict[str, type] = OrderedDict() - schema["username"] = str - schema["password"] = str - return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + } + ), + errors=errors, ) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index af24506210b..2cb113b8b8c 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -102,5 +102,7 @@ class LegacyLoginFlow(LoginFlow): return await self.async_finish({}) return self.async_show_form( - step_id="init", data_schema=vol.Schema({"password": str}), errors=errors + step_id="init", + data_schema=vol.Schema({vol.Required("password"): str}), + errors=errors, ) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index a9ee6a48335..0f2b287a227 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -244,5 +244,7 @@ class TrustedNetworksLoginFlow(LoginFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema({"user": vol.In(self._available_users)}), + data_schema=vol.Schema( + {vol.Required("user"): vol.In(self._available_users)} + ), ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 66312f7283a..f2b3d5e6ec4 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -109,9 +109,8 @@ async def async_setup_hass( config_dict = None basic_setup_success = False - safe_mode = runtime_config.safe_mode - if not safe_mode: + if not (safe_mode := runtime_config.safe_mode): await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: @@ -368,8 +367,7 @@ async def async_mount_local_lib_path(config_dir: str) -> str: This function is a coroutine. """ deps_dir = os.path.join(config_dir, "deps") - lib_dir = await async_get_user_site(deps_dir) - if lib_dir not in sys.path: + if (lib_dir := await async_get_user_site(deps_dir)) not in sys.path: sys.path.insert(0, lib_dir) return deps_dir @@ -494,17 +492,13 @@ async def _async_set_up_integrations( _LOGGER.info("Domains to be set up: %s", domains_to_setup) - logging_domains = domains_to_setup & LOGGING_INTEGRATIONS - # Load logging as soon as possible - if logging_domains: + if logging_domains := domains_to_setup & LOGGING_INTEGRATIONS: _LOGGER.info("Setting up logging: %s", logging_domains) await async_setup_multi_components(hass, logging_domains, config) # Start up debuggers. Start these first in case they want to wait. - debuggers = domains_to_setup & DEBUGGER_INTEGRATIONS - - if debuggers: + if debuggers := domains_to_setup & DEBUGGER_INTEGRATIONS: _LOGGER.debug("Setting up debuggers: %s", debuggers) await async_setup_multi_components(hass, debuggers, config) @@ -524,9 +518,7 @@ async def _async_set_up_integrations( stage_1_domains.add(domain) - dep_itg = integration_cache.get(domain) - - if dep_itg is None: + if (dep_itg := integration_cache.get(domain)) is None: continue deps_promotion.update(dep_itg.all_dependencies) @@ -564,6 +556,14 @@ async def _async_set_up_integrations( except asyncio.TimeoutError: _LOGGER.warning("Setup timed out for stage 2 - moving forward") + # Wrap up startup + _LOGGER.debug("Waiting for startup to wrap up") + try: + async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): + await hass.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for bootstrap - moving forward") + watch_task.cancel() async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATONS, {}) @@ -576,11 +576,3 @@ async def _async_set_up_integrations( ) }, ) - - # Wrap up startup - _LOGGER.debug("Waiting for startup to wrap up") - try: - async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): - await hass.async_block_till_done() - except asyncio.TimeoutError: - _LOGGER.warning("Setup timed out for bootstrap - moving forward") diff --git a/homeassistant/components/abode/translations/fr.json b/homeassistant/components/abode/translations/fr.json index 2ab158cca57..fb5a079d405 100644 --- a/homeassistant/components/abode/translations/fr.json +++ b/homeassistant/components/abode/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", - "single_instance_allowed": "D\u00e9ja configur\u00e9. Une seule configuration possible." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/accuweather/translations/fr.json b/homeassistant/components/accuweather/translations/fr.json index a083ed09bdf..7c04e51da23 100644 --- a/homeassistant/components/accuweather/translations/fr.json +++ b/homeassistant/components/accuweather/translations/fr.json @@ -14,7 +14,7 @@ "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", - "name": "Nom de l'int\u00e9gration" + "name": "Nom" }, "description": "Si vous avez besoin d'aide pour la configuration, consultez le site suivant : https://www.home-assistant.io/integrations/accuweather/\n\nCertains capteurs ne sont pas activ\u00e9s par d\u00e9faut. Vous pouvez les activer dans le registre des entit\u00e9s apr\u00e8s la configuration de l'int\u00e9gration.\nLes pr\u00e9visions m\u00e9t\u00e9orologiques ne sont pas activ\u00e9es par d\u00e9faut. Vous pouvez l'activer dans les options d'int\u00e9gration.", "title": "AccuWeather" diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index 7b4d270f78b..8b0409d1f22 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", - "requests_exceeded": "T\u00fall\u00e9pt\u00e9k az Accuweather API-hoz beny\u00fajtott k\u00e9relmek megengedett sz\u00e1m\u00e1t. Meg kell v\u00e1rnia vagy m\u00f3dos\u00edtania kell az API-kulcsot." + "requests_exceeded": "Accuweather API-hoz enged\u00e9lyezett lek\u00e9r\u00e9sek sz\u00e1ma t\u00fal lett l\u00e9pve. Meg kell v\u00e1rnia m\u00edg a tilt\u00e1s lej\u00e1r vagy m\u00f3dos\u00edtania kell az API-kulcsot." }, "step": { "user": { diff --git a/homeassistant/components/adax/translations/es.json b/homeassistant/components/adax/translations/es.json index 4a65e469bcd..985d0ab663f 100644 --- a/homeassistant/components/adax/translations/es.json +++ b/homeassistant/components/adax/translations/es.json @@ -1,9 +1,18 @@ { "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": { - "account_id": "ID de la cuenta" + "account_id": "ID de la cuenta", + "host": "Host", + "password": "Contrase\u00f1a" } } } diff --git a/homeassistant/components/adax/translations/hu.json b/homeassistant/components/adax/translations/hu.json index 726381a4dd7..94397487c87 100644 --- a/homeassistant/components/adax/translations/hu.json +++ b/homeassistant/components/adax/translations/hu.json @@ -11,7 +11,7 @@ "user": { "data": { "account_id": "Fi\u00f3k ID", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3" } } diff --git a/homeassistant/components/adax/translations/id.json b/homeassistant/components/adax/translations/id.json new file mode 100644 index 00000000000..e554913bdc8 --- /dev/null +++ b/homeassistant/components/adax/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "account_id": "ID Akun", + "host": "Host", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/fr.json b/homeassistant/components/adguard/translations/fr.json index 7add7c9829f..da6dd866983 100644 --- a/homeassistant/components/adguard/translations/fr.json +++ b/homeassistant/components/adguard/translations/fr.json @@ -17,9 +17,9 @@ "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "ssl": "AdGuard Home utilise un certificat SSL", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "AdGuard Home utilise un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Configurez votre instance AdGuard Home pour permettre la surveillance et le contr\u00f4le." } diff --git a/homeassistant/components/adguard/translations/he.json b/homeassistant/components/adguard/translations/he.json index e2114d19d97..9970667cf40 100644 --- a/homeassistant/components/adguard/translations/he.json +++ b/homeassistant/components/adguard/translations/he.json @@ -7,6 +7,9 @@ "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "step": { + "hassio_confirm": { + "title": "AdGuard Home \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05e8\u05d7\u05d1\u05ea Assistant Assistant" + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 8a860caf79d..b04d67fbb89 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -9,12 +9,12 @@ }, "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} ?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot AdGuard Home-hoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "Az AdGuard Home a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", diff --git a/homeassistant/components/adguard/translations/id.json b/homeassistant/components/adguard/translations/id.json index d3334997f59..91d06526184 100644 --- a/homeassistant/components/adguard/translations/id.json +++ b/homeassistant/components/adguard/translations/id.json @@ -9,7 +9,7 @@ }, "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke AdGuard Home yang disediakan oleh add-on Supervisor {addon}?", + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke AdGuard Home yang disediakan oleh add-on: {addon}?", "title": "AdGuard Home melalui add-on Home Assistant" }, "user": { diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 0927f64dd2a..e84060b444d 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,5 +1,7 @@ """Constant values for the AEMET OpenData component.""" +from __future__ import annotations +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -40,9 +42,6 @@ DEFAULT_NAME = "AEMET" DOMAIN = "aemet" ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" -SENSOR_NAME = "sensor_name" -SENSOR_UNIT = "sensor_unit" -SENSOR_DEVICE_CLASS = "sensor_device_class" ATTR_API_CONDITION = "condition" ATTR_API_FORECAST_DAILY = "forecast-daily" @@ -200,118 +199,145 @@ FORECAST_MODE_ATTR_API = { FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } -FORECAST_SENSOR_TYPES = { - ATTR_FORECAST_CONDITION: { - SENSOR_NAME: "Condition", - }, - ATTR_FORECAST_PRECIPITATION: { - SENSOR_NAME: "Precipitation", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: { - SENSOR_NAME: "Precipitation probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_FORECAST_TEMP: { - SENSOR_NAME: "Temperature", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_FORECAST_TEMP_LOW: { - SENSOR_NAME: "Temperature Low", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_FORECAST_TIME: { - SENSOR_NAME: "Time", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_FORECAST_WIND_BEARING: { - SENSOR_NAME: "Wind bearing", - SENSOR_UNIT: DEGREE, - }, - ATTR_FORECAST_WIND_SPEED: { - SENSOR_NAME: "Wind speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, -} -WEATHER_SENSOR_TYPES = { - ATTR_API_CONDITION: { - SENSOR_NAME: "Condition", - }, - ATTR_API_HUMIDITY: { - SENSOR_NAME: "Humidity", - SENSOR_UNIT: PERCENTAGE, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - ATTR_API_PRESSURE: { - SENSOR_NAME: "Pressure", - SENSOR_UNIT: PRESSURE_HPA, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - }, - ATTR_API_RAIN: { - SENSOR_NAME: "Rain", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_API_RAIN_PROB: { - SENSOR_NAME: "Rain probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_SNOW: { - SENSOR_NAME: "Snow", - SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR, - }, - ATTR_API_SNOW_PROB: { - SENSOR_NAME: "Snow probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_STATION_ID: { - SENSOR_NAME: "Station ID", - }, - ATTR_API_STATION_NAME: { - SENSOR_NAME: "Station name", - }, - ATTR_API_STATION_TIMESTAMP: { - SENSOR_NAME: "Station timestamp", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_API_STORM_PROB: { - SENSOR_NAME: "Storm probability", - SENSOR_UNIT: PERCENTAGE, - }, - ATTR_API_TEMPERATURE: { - SENSOR_NAME: "Temperature", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_API_TEMPERATURE_FEELING: { - SENSOR_NAME: "Temperature feeling", - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - ATTR_API_TOWN_ID: { - SENSOR_NAME: "Town ID", - }, - ATTR_API_TOWN_NAME: { - SENSOR_NAME: "Town name", - }, - ATTR_API_TOWN_TIMESTAMP: { - SENSOR_NAME: "Town timestamp", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, - ATTR_API_WIND_BEARING: { - SENSOR_NAME: "Wind bearing", - SENSOR_UNIT: DEGREE, - }, - ATTR_API_WIND_MAX_SPEED: { - SENSOR_NAME: "Wind max speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, - ATTR_API_WIND_SPEED: { - SENSOR_NAME: "Wind speed", - SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR, - }, -} +FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_FORECAST_CONDITION, + name="Condition", + ), + SensorEntityDescription( + key=ATTR_FORECAST_PRECIPITATION, + name="Precipitation", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + name="Precipitation probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TEMP_LOW, + name="Temperature Low", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_TIME, + name="Time", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_FORECAST_WIND_BEARING, + name="Wind bearing", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=ATTR_FORECAST_WIND_SPEED, + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), +) +WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_CONDITION, + name="Condition", + ), + SensorEntityDescription( + key=ATTR_API_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=ATTR_API_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=ATTR_API_RAIN, + name="Rain", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_RAIN_PROB, + name="Rain probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_API_SNOW, + name="Snow", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_SNOW_PROB, + name="Snow probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_API_STATION_ID, + name="Station ID", + ), + SensorEntityDescription( + key=ATTR_API_STATION_NAME, + name="Station name", + ), + SensorEntityDescription( + key=ATTR_API_STATION_TIMESTAMP, + name="Station timestamp", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_API_STORM_PROB, + name="Storm probability", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=ATTR_API_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_API_TEMPERATURE_FEELING, + name="Temperature feeling", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=ATTR_API_TOWN_ID, + name="Town ID", + ), + SensorEntityDescription( + key=ATTR_API_TOWN_NAME, + name="Town name", + ), + SensorEntityDescription( + key=ATTR_API_TOWN_TIMESTAMP, + name="Town timestamp", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=ATTR_API_WIND_BEARING, + name="Wind bearing", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=ATTR_API_WIND_MAX_SPEED, + name="Wind max speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), + SensorEntityDescription( + key=ATTR_API_WIND_SPEED, + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + ), +) WIND_BEARING_MAP = { "C": None, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 35336980e1a..685e9fb200b 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -1,5 +1,7 @@ """Support for the AEMET OpenData service.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -14,9 +16,6 @@ from .const import ( FORECAST_MONITORED_CONDITIONS, FORECAST_SENSOR_TYPES, MONITORED_CONDITIONS, - SENSOR_DEVICE_CLASS, - SENSOR_NAME, - SENSOR_UNIT, WEATHER_SENSOR_TYPES, ) from .weather_update_coordinator import WeatherUpdateCoordinator @@ -28,37 +27,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = domain_data[ENTRY_NAME] weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] - weather_sensor_types = WEATHER_SENSOR_TYPES - forecast_sensor_types = FORECAST_SENSOR_TYPES - - entities = [] - for sensor_type in MONITORED_CONDITIONS: - unique_id = f"{config_entry.unique_id}-{sensor_type}" - entities.append( - AemetSensor( - name, - unique_id, - sensor_type, - weather_sensor_types[sensor_type], + unique_id = config_entry.unique_id + entities: list[AbstractAemetSensor] = [ + AemetSensor(name, unique_id, weather_coordinator, description) + for description in WEATHER_SENSOR_TYPES + if description.key in MONITORED_CONDITIONS + ] + entities.extend( + [ + AemetForecastSensor( + name_prefix, + unique_id_prefix, weather_coordinator, + mode, + description, ) - ) - - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - - for sensor_type in FORECAST_MONITORED_CONDITIONS: - unique_id = f"{config_entry.unique_id}-forecast-{mode}-{sensor_type}" - entities.append( - AemetForecastSensor( - f"{name} Forecast", - unique_id, - sensor_type, - forecast_sensor_types[sensor_type], - weather_coordinator, - mode, - ) + for mode in FORECAST_MODES + if ( + (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") + and (unique_id_prefix := f"{unique_id}-forecast-{mode}") ) + for description in FORECAST_SENSOR_TYPES + if description.key in FORECAST_MONITORED_CONDITIONS + ] + ) async_add_entities(entities) @@ -72,20 +64,14 @@ class AbstractAemetSensor(CoordinatorEntity, SensorEntity): self, name, unique_id, - sensor_type, - sensor_configuration, coordinator: WeatherUpdateCoordinator, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__(coordinator) - self._name = name - self._unique_id = unique_id - self._sensor_type = sensor_type - self._sensor_name = sensor_configuration[SENSOR_NAME] - 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_native_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id class AemetSensor(AbstractAemetSensor): @@ -95,20 +81,21 @@ class AemetSensor(AbstractAemetSensor): self, name, unique_id, - sensor_type, - sensor_configuration, weather_coordinator: WeatherUpdateCoordinator, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, weather_coordinator + name=name, + unique_id=f"{unique_id}-{description.key}", + coordinator=weather_coordinator, + description=description, ) - self._weather_coordinator = weather_coordinator @property def native_value(self): """Return the state of the device.""" - return self._weather_coordinator.data.get(self._sensor_type) + return self.coordinator.data.get(self.entity_description.key) class AemetForecastSensor(AbstractAemetSensor): @@ -118,16 +105,17 @@ class AemetForecastSensor(AbstractAemetSensor): self, name, unique_id, - sensor_type, - sensor_configuration, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, + description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, weather_coordinator + name=name, + unique_id=f"{unique_id}-{description.key}", + coordinator=weather_coordinator, + description=description, ) - self._weather_coordinator = weather_coordinator self._forecast_mode = forecast_mode self._attr_entity_registry_enabled_default = ( self._forecast_mode == FORECAST_MODE_DAILY @@ -137,9 +125,9 @@ class AemetForecastSensor(AbstractAemetSensor): def native_value(self): """Return the state of the device.""" forecast = None - forecasts = self._weather_coordinator.data.get( + forecasts = self.coordinator.data.get( FORECAST_MODE_ATTR_API[self._forecast_mode] ) if forecasts: - forecast = forecasts[0].get(self._sensor_type) + forecast = forecasts[0].get(self.entity_description.key) return forecast diff --git a/homeassistant/components/agent_dvr/translations/fr.json b/homeassistant/components/agent_dvr/translations/fr.json index e78c1da7d8b..1b641dd38ab 100644 --- a/homeassistant/components/agent_dvr/translations/fr.json +++ b/homeassistant/components/agent_dvr/translations/fr.json @@ -4,13 +4,13 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "already_in_progress": "La configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Configurer l'agent DVR" diff --git a/homeassistant/components/agent_dvr/translations/hu.json b/homeassistant/components/agent_dvr/translations/hu.json index fff86517073..b8fec1c281d 100644 --- a/homeassistant/components/agent_dvr/translations/hu.json +++ b/homeassistant/components/agent_dvr/translations/hu.json @@ -4,13 +4,13 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "\u00c1ll\u00edtsa be az Agent DVR-t" diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 79004abbe41..c583a56c22b 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -3,23 +3,6 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - DEVICE_CLASS_AQI, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - PRESSURE_HPA, - TEMP_CELSIUS, -) - -from .model import AirlySensorEntityDescription - ATTR_API_ADVICE: Final = "ADVICE" ATTR_API_CAQI: Final = "CAQI" ATTR_API_CAQI_DESCRIPTION: Final = "DESCRIPTION" @@ -49,56 +32,3 @@ MANUFACTURER: Final = "Airly sp. z o.o." 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: tuple[AirlySensorEntityDescription, ...] = ( - AirlySensorEntityDescription( - key=ATTR_API_CAQI, - device_class=DEVICE_CLASS_AQI, - name=ATTR_API_CAQI, - native_unit_of_measurement="CAQI", - ), - AirlySensorEntityDescription( - key=ATTR_API_PM1, - device_class=DEVICE_CLASS_PM1, - name=ATTR_API_PM1, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, - ), - AirlySensorEntityDescription( - key=ATTR_API_PM25, - device_class=DEVICE_CLASS_PM25, - name="PM2.5", - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, - ), - AirlySensorEntityDescription( - key=ATTR_API_PM10, - device_class=DEVICE_CLASS_PM10, - name=ATTR_API_PM10, - native_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(), - native_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(), - native_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(), - native_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 deleted file mode 100644 index 38b433de34c..00000000000 --- a/homeassistant/components/airly/model.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Type definitions for Airly integration.""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass -class AirlySensorEntityDescription(SensorEntityDescription): - """Class describing Airly sensor entities.""" - - value: Callable = round diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index b5d45afd2d8..fc587f15140 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -1,11 +1,31 @@ """Support for the Airly sensor service.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONF_NAME, + DEVICE_CLASS_AQI, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_HPA, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -18,8 +38,12 @@ from .const import ( ATTR_API_CAQI, ATTR_API_CAQI_DESCRIPTION, ATTR_API_CAQI_LEVEL, + ATTR_API_HUMIDITY, + ATTR_API_PM1, ATTR_API_PM10, ATTR_API_PM25, + ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, ATTR_DESCRIPTION, ATTR_LEVEL, ATTR_LIMIT, @@ -28,15 +52,74 @@ from .const import ( DEFAULT_NAME, DOMAIN, MANUFACTURER, - SENSOR_TYPES, SUFFIX_LIMIT, SUFFIX_PERCENT, ) -from .model import AirlySensorEntityDescription PARALLEL_UPDATES = 1 +@dataclass +class AirlySensorEntityDescription(SensorEntityDescription): + """Class describing Airly sensor entities.""" + + value: Callable = round + + +SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( + AirlySensorEntityDescription( + key=ATTR_API_CAQI, + device_class=DEVICE_CLASS_AQI, + name=ATTR_API_CAQI, + native_unit_of_measurement="CAQI", + ), + AirlySensorEntityDescription( + key=ATTR_API_PM1, + device_class=DEVICE_CLASS_PM1, + name=ATTR_API_PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM25, + device_class=DEVICE_CLASS_PM25, + name="PM2.5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM10, + device_class=DEVICE_CLASS_PM10, + name=ATTR_API_PM10, + native_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(), + native_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(), + native_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(), + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value, 1), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/airly/translations/fr.json b/homeassistant/components/airly/translations/fr.json index a23f455e0b8..945de28e07a 100644 --- a/homeassistant/components/airly/translations/fr.json +++ b/homeassistant/components/airly/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration des coordonn\u00e9es d'Airly est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_api_key": "Cl\u00e9 API invalide", @@ -13,7 +13,7 @@ "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", - "name": "Nom de l'int\u00e9gration" + "name": "Nom" }, "description": "Configurez l'int\u00e9gration de la qualit\u00e9 de l'air Airly. Pour g\u00e9n\u00e9rer une cl\u00e9 API, rendez-vous sur https://developer.airly.eu/register.", "title": "Airly" diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index a0f8d7e701b..b0d8c69cff2 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,14 +1,19 @@ """Support for the AirNow sensor service.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirNowDataUpdateCoordinator from .const import ( ATTR_API_AQI, ATTR_API_AQI_DESCRIPTION, @@ -22,69 +27,72 @@ from .const import ( ATTRIBUTION = "Data provided by AirNow" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" - PARALLEL_UPDATES = 1 -SENSOR_TYPES = { - ATTR_API_AQI: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_AQI, - ATTR_UNIT: "aqi", - }, - ATTR_API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_PM25, - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - ATTR_API_O3: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_O3, - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_API_AQI, + icon="mdi:blur", + name=ATTR_API_AQI, + native_unit_of_measurement="aqi", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_PM25, + icon="mdi:blur", + name=ATTR_API_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_API_O3, + icon="mdi:blur", + name=ATTR_API_O3, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up AirNow sensor entities based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] - for sensor in SENSOR_TYPES: - sensors.append(AirNowSensor(coordinator, sensor)) + entities = [AirNowSensor(coordinator, description) for description in SENSOR_TYPES] - async_add_entities(sensors, False) + async_add_entities(entities, False) class AirNowSensor(CoordinatorEntity, SensorEntity): """Define an AirNow sensor.""" - def __init__(self, coordinator, kind): + coordinator: AirNowDataUpdateCoordinator + + def __init__( + self, + coordinator: AirNowDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) - self.kind = kind + self.entity_description = description self._state = None self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - 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_native_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] - self._attr_unique_id = f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" + self._attr_name = f"AirNow {description.name}" + self._attr_unique_id = ( + f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" + ) @property def native_value(self): """Return the state.""" - self._state = self.coordinator.data[self.kind] + self._state = self.coordinator.data[self.entity_description.key] return self._state @property def extra_state_attributes(self): """Return the state attributes.""" - if self.kind == ATTR_API_AQI: + if self.entity_description.key == ATTR_API_AQI: self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[ ATTR_API_AQI_DESCRIPTION ] diff --git a/homeassistant/components/airnow/translations/fr.json b/homeassistant/components/airnow/translations/fr.json index ff85d9318e9..686dedb9bb6 100644 --- a/homeassistant/components/airnow/translations/fr.json +++ b/homeassistant/components/airnow/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "invalid_location": "Aucun r\u00e9sultat trouv\u00e9 pour cet emplacement", "unknown": "Erreur inattendue" @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "api_key": "Cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", "radius": "Rayon d'action de la station (en miles, facultatif)" diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py new file mode 100644 index 00000000000..601396d36da --- /dev/null +++ b/homeassistant/components/airthings/__init__.py @@ -0,0 +1,61 @@ +"""The Airthings integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from airthings import Airthings, AirthingsError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["sensor"] +SCAN_INTERVAL = timedelta(minutes=6) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airthings from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + airthings = Airthings( + entry.data[CONF_ID], + entry.data[CONF_SECRET], + async_get_clientsession(hass), + ) + + async def _update_method(): + """Get the latest data from Airthings.""" + try: + return await airthings.update_devices() + except AirthingsError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_update_method, + update_interval=SCAN_INTERVAL, + ) + 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) -> 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/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py new file mode 100644 index 00000000000..842f05d76db --- /dev/null +++ b/homeassistant/components/airthings/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Airthings integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import airthings +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID): str, + vol.Required(CONF_SECRET): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airthings.""" + + 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, + description_placeholders={ + "url": "https://dashboard.airthings.com/integrations/api-integration", + }, + ) + + errors = {} + + try: + await airthings.get_token( + async_get_clientsession(self.hass), + user_input[CONF_ID], + user_input[CONF_SECRET], + ) + except airthings.AirthingsConnectionError: + errors["base"] = "cannot_connect" + except airthings.AirthingsAuthError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Airthings", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py new file mode 100644 index 00000000000..70de549141b --- /dev/null +++ b/homeassistant/components/airthings/const.py @@ -0,0 +1,6 @@ +"""Constants for the Airthings integration.""" + +DOMAIN = "airthings" + +CONF_ID = "id" +CONF_SECRET = "secret" diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json new file mode 100644 index 00000000000..749a5e44992 --- /dev/null +++ b/homeassistant/components/airthings/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airthings", + "name": "Airthings", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airthings", + "requirements": ["airthings_cloud==0.0.1"], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py new file mode 100644 index 00000000000..4aab2307d9a --- /dev/null +++ b/homeassistant/components/airthings/sensor.py @@ -0,0 +1,164 @@ +"""Support for Airthings sensors.""" +from __future__ import annotations + +from airthings import AirthingsDevice + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_MBAR, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +SENSORS: dict[str, SensorEntityDescription] = { + "radonShortTermAvg": SensorEntityDescription( + key="radonShortTermAvg", + native_unit_of_measurement="Bq/m³", + name="Radon", + ), + "temp": SensorEntityDescription( + key="temp", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + ), + "humidity": SensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + ), + "pressure": SensorEntityDescription( + key="pressure", + device_class=DEVICE_CLASS_PRESSURE, + native_unit_of_measurement=PRESSURE_MBAR, + name="Pressure", + ), + "battery": SensorEntityDescription( + key="battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, + name="Battery", + ), + "co2": SensorEntityDescription( + key="co2", + device_class=DEVICE_CLASS_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="CO2", + ), + "voc": SensorEntityDescription( + key="voc", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="VOC", + ), + "light": SensorEntityDescription( + key="light", + native_unit_of_measurement=PERCENTAGE, + name="Light", + ), + "virusRisk": SensorEntityDescription( + key="virusRisk", + name="Virus Risk", + ), + "mold": SensorEntityDescription( + key="mold", + name="Mold", + ), + "rssi": SensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + name="RSSI", + entity_registry_enabled_default=False, + ), + "pm1": SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM1, + name="PM1", + ), + "pm25": SensorEntityDescription( + key="pm25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + name="PM25", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airthings sensor.""" + + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [ + AirthingsHeaterEnergySensor( + coordinator, + airthings_device, + SENSORS[sensor_types], + ) + for airthings_device in coordinator.data.values() + for sensor_types in airthings_device.sensor_types + if sensor_types in SENSORS + ] + async_add_entities(entities) + + +class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): + """Representation of a Airthings Sensor device.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, + coordinator: DataUpdateCoordinator, + airthings_device: AirthingsDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_name = f"{airthings_device.name} {entity_description.name}" + self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" + self._id = airthings_device.device_id + self._attr_device_info = { + "identifiers": {(DOMAIN, airthings_device.device_id)}, + "name": airthings_device.name, + "manufacturer": "Airthings", + } + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self._id].sensors[self.entity_description.key] diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json new file mode 100644 index 00000000000..32f3fbc6954 --- /dev/null +++ b/homeassistant/components/airthings/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "id": "ID", + "secret": "Secret", + "description": "Login at {url} to find your credentials" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ca.json b/homeassistant/components/airthings/translations/ca.json new file mode 100644 index 00000000000..c90f9cc6364 --- /dev/null +++ b/homeassistant/components/airthings/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "description": "Inicia sessi\u00f3 a {url} per obtenir les credencials", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/de.json b/homeassistant/components/airthings/translations/de.json new file mode 100644 index 00000000000..7bd5e347776 --- /dev/null +++ b/homeassistant/components/airthings/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "description": "Melde dich unter {url} an, um deine Zugangsdaten zu finden", + "id": "ID", + "secret": "Geheimnis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/en.json b/homeassistant/components/airthings/translations/en.json new file mode 100644 index 00000000000..a7430dedd81 --- /dev/null +++ b/homeassistant/components/airthings/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "description": "Login at {url} to find your credentials", + "id": "ID", + "secret": "Secret" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/et.json b/homeassistant/components/airthings/translations/et.json new file mode 100644 index 00000000000..708416f16c1 --- /dev/null +++ b/homeassistant/components/airthings/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise t\u00f5rge", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "description": "Logi sisse aadressil {url}, et leida oma mandaadid", + "id": "Kasutajatunnus", + "secret": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/he.json b/homeassistant/components/airthings/translations/he.json new file mode 100644 index 00000000000..c6c0d910ae4 --- /dev/null +++ b/homeassistant/components/airthings/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "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", + "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": { + "id": "\u05de\u05d6\u05d4\u05d4", + "secret": "\u05e1\u05d5\u05d3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/hu.json b/homeassistant/components/airthings/translations/hu.json new file mode 100644 index 00000000000..136348d38b4 --- /dev/null +++ b/homeassistant/components/airthings/translations/hu.json @@ -0,0 +1,21 @@ +{ + "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", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "description": "Jelentkezzen be a {url} c\u00edmen hogy megkapja hiteles\u00edt\u0151 adatait", + "id": "Azonos\u00edt\u00f3", + "secret": "Titok" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/it.json b/homeassistant/components/airthings/translations/it.json new file mode 100644 index 00000000000..68a0c152f56 --- /dev/null +++ b/homeassistant/components/airthings/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "description": "Accedi a {url} per trovare le tue credenziali", + "id": "ID", + "secret": "Segreto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/nl.json b/homeassistant/components/airthings/translations/nl.json new file mode 100644 index 00000000000..3f0e753b375 --- /dev/null +++ b/homeassistant/components/airthings/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "description": "Log in op {url} om uw inloggegevens te vinden", + "id": "ID", + "secret": "Geheim" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/no.json b/homeassistant/components/airthings/translations/no.json new file mode 100644 index 00000000000..8609dff2e16 --- /dev/null +++ b/homeassistant/components/airthings/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "description": "Logg p\u00e5 {url} \u00e5 finne legitimasjonen din", + "id": "ID", + "secret": "Hemmelig" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ru.json b/homeassistant/components/airthings/translations/ru.json new file mode 100644 index 00000000000..6ec7077860e --- /dev/null +++ b/homeassistant/components/airthings/translations/ru.json @@ -0,0 +1,21 @@ +{ + "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.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "description": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u0430\u043d\u043d\u044b\u0435 \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430 \u044d\u0442\u043e\u0439 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435: {url}", + "id": "ID", + "secret": "\u0421\u0435\u043a\u0440\u0435\u0442\u043d\u044b\u0439 \u043a\u043e\u0434" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/zh-Hant.json b/homeassistant/components/airthings/translations/zh-Hant.json new file mode 100644 index 00000000000..0cafeb9886d --- /dev/null +++ b/homeassistant/components/airthings/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "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", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "description": "\u767b\u5165 {url} \u4ee5\u53d6\u5f97\u6191\u8b49", + "id": "ID", + "secret": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/el.json b/homeassistant/components/airtouch4/translations/el.json new file mode 100644 index 00000000000..004cb1a268f --- /dev/null +++ b/homeassistant/components/airtouch4/translations/el.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 {intergration}." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/es.json b/homeassistant/components/airtouch4/translations/es.json new file mode 100644 index 00000000000..65616d2a2e9 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "no_units": "No se pudo encontrar ning\u00fan grupo AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Configura los detalles de conexi\u00f3n de tu AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/fr.json b/homeassistant/components/airtouch4/translations/fr.json new file mode 100644 index 00000000000..33580a8eae3 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/hu.json b/homeassistant/components/airtouch4/translations/hu.json index c5d54de31de..861582fad3e 100644 --- a/homeassistant/components/airtouch4/translations/hu.json +++ b/homeassistant/components/airtouch4/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" }, "title": "\u00c1ll\u00edtsa be az AirTouch 4 csatlakoz\u00e1si adatait." } diff --git a/homeassistant/components/airtouch4/translations/id.json b/homeassistant/components/airtouch4/translations/id.json new file mode 100644 index 00000000000..c8236f5ec73 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 72f94875ea8..486ef072f24 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,7 +1,11 @@ """Support for AirVisual air quality sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, @@ -76,6 +80,7 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = ( name="Air Quality Index", device_class=DEVICE_CLASS_AQI, native_unit_of_measurement="AQI", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_POLLUTANT, @@ -92,6 +97,7 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="Air Quality Index", device_class=DEVICE_CLASS_AQI, native_unit_of_measurement="AQI", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_BATTERY_LEVEL, @@ -104,6 +110,7 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="C02", device_class=DEVICE_CLASS_CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_HUMIDITY, @@ -116,30 +123,35 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="PM 0.1", device_class=DEVICE_CLASS_PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_PM_1_0, name="PM 1.0", device_class=DEVICE_CLASS_PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_PM_2_5, name="PM 2.5", device_class=DEVICE_CLASS_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_TEMPERATURE, name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_VOC, name="VOC", device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/airvisual/translations/fr.json b/homeassistant/components/airvisual/translations/fr.json index 510bf8597d1..4817a720225 100644 --- a/homeassistant/components/airvisual/translations/fr.json +++ b/homeassistant/components/airvisual/translations/fr.json @@ -13,7 +13,7 @@ "step": { "geography_by_coords": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude" }, @@ -22,7 +22,7 @@ }, "geography_by_name": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "city": "Ville", "country": "Pays", "state": "Etat" diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json index 043a2402283..48d4f5b98eb 100644 --- a/homeassistant/components/airvisual/translations/hu.json +++ b/homeassistant/components/airvisual/translations/hu.json @@ -32,7 +32,7 @@ }, "node_pro": { "data": { - "ip_address": "Hoszt", + "ip_address": "C\u00edm", "password": "Jelsz\u00f3" }, "description": "Szem\u00e9lyes AirVisual egys\u00e9g figyel\u00e9se. A jelsz\u00f3 lek\u00e9rhet\u0151 a k\u00e9sz\u00fcl\u00e9k felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9r\u0151l.", diff --git a/homeassistant/components/airvisual/translations/sensor.es.json b/homeassistant/components/airvisual/translations/sensor.es.json index 4a8a7cea1e3..113c17246ed 100644 --- a/homeassistant/components/airvisual/translations/sensor.es.json +++ b/homeassistant/components/airvisual/translations/sensor.es.json @@ -9,11 +9,11 @@ "s2": "Di\u00f3xido de azufre" }, "airvisual__pollutant_level": { - "good": "Bien", - "hazardous": "Peligroso", + "good": "Bueno", + "hazardous": "Da\u00f1ino", "moderate": "Moderado", "unhealthy": "Insalubre", - "unhealthy_sensitive": "Incorrecto para grupos sensibles", + "unhealthy_sensitive": "Insalubre para grupos sensibles", "very_unhealthy": "Muy poco saludable" } } diff --git a/homeassistant/components/airvisual/translations/sensor.pl.json b/homeassistant/components/airvisual/translations/sensor.pl.json index 48835f36f69..3ac9e2c2c28 100644 --- a/homeassistant/components/airvisual/translations/sensor.pl.json +++ b/homeassistant/components/airvisual/translations/sensor.pl.json @@ -1,12 +1,12 @@ { "state": { "airvisual__pollutant_label": { - "co": "Tlenek w\u0119gla", - "n2": "Dwutlenek azotu", - "o3": "Ozon", + "co": "tlenek w\u0119gla", + "n2": "dwutlenek azotu", + "o3": "ozon", "p1": "PM10", "p2": "PM2.5", - "s2": "Dwutlenek siarki" + "s2": "dwutlenek siarki" }, "airvisual__pollutant_level": { "good": "dobry", diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 695ec0ebb4a..92c73b07bbd 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -11,7 +11,10 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_NIGHT, SUPPORT_ALARM_ARM_VACATION, ) -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -129,7 +132,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "triggered": diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json index 0d81ed505f9..5527101589b 100644 --- a/homeassistant/components/alarm_control_panel/translations/nl.json +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -4,7 +4,7 @@ "arm_away": "Schakel {entity_name} in voor vertrek", "arm_home": "Schakel {entity_name} in voor thuis", "arm_night": "Schakel {entity_name} in voor 's nachts", - "arm_vacation": "Schakel {entity_name} in op vakantie", + "arm_vacation": "Schakel {entity_name} in voor vakantie", "disarm": "Schakel {entity_name} uit", "trigger": "Laat {entity_name} afgaan" }, @@ -12,7 +12,7 @@ "is_armed_away": "{entity_name} ingeschakeld voor vertrek", "is_armed_home": "{entity_name} ingeschakeld voor thuis", "is_armed_night": "{entity_name} is ingeschakeld voor 's nachts", - "is_armed_vacation": "{entity_name} is in vakantie geschakeld", + "is_armed_vacation": "{entity_name} is ingeschakeld voor vakantie", "is_disarmed": "{entity_name} is uitgeschakeld", "is_triggered": "{entity_name} gaat af" }, @@ -20,7 +20,7 @@ "armed_away": "{entity_name} ingeschakeld voor vertrek", "armed_home": "{entity_name} ingeschakeld voor thuis", "armed_night": "{entity_name} ingeschakeld voor 's nachts", - "armed_vacation": "{entity_name} schakelde vakantie in", + "armed_vacation": "{entity_name} schakelde in voor vakantie", "disarmed": "{entity_name} uitgeschakeld", "triggered": "{entity_name} afgegaan" } @@ -40,5 +40,5 @@ "triggered": "Gaat af" } }, - "title": "Alarm bedieningspaneel" + "title": "Alarmbedieningspaneel" } \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/hu.json b/homeassistant/components/alarmdecoder/translations/hu.json index ace9c7059ca..3c9781672f4 100644 --- a/homeassistant/components/alarmdecoder/translations/hu.json +++ b/homeassistant/components/alarmdecoder/translations/hu.json @@ -14,7 +14,7 @@ "data": { "device_baudrate": "Eszk\u00f6z \u00e1tviteli sebess\u00e9ge", "device_path": "Eszk\u00f6z el\u00e9r\u00e9si \u00fatja", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Konfigur\u00e1lja a csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sokat" diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index fcd6ebf6ae2..46f421963ca 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -48,6 +48,7 @@ from .const import ( API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, DATE_FORMAT, + PRESET_MODE_NA, Inputs, ) from .errors import UnsupportedProperty @@ -391,6 +392,8 @@ class AlexaPowerController(AlexaCapability): if self.entity.domain == climate.DOMAIN: is_on = self.entity.state != climate.HVAC_MODE_OFF + elif self.entity.domain == fan.DOMAIN: + is_on = self.entity.state == fan.STATE_ON elif self.entity.domain == vacuum.DOMAIN: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: @@ -1155,9 +1158,6 @@ class AlexaPowerLevelController(AlexaCapability): if name != "powerLevel": raise UnsupportedProperty(name) - if self.entity.domain == fan.DOMAIN: - return self.entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - class AlexaSecurityPanelController(AlexaCapability): """Implements Alexa.SecurityPanelController. @@ -1354,10 +1354,17 @@ class AlexaModeController(AlexaCapability): self._resource = AlexaModeResource( [AlexaGlobalCatalog.SETTING_PRESET], False ) - for preset_mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, []): + preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES, []) + for preset_mode in preset_modes: self._resource.add_mode( f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode] ) + # Fans with a single preset_mode completely break Alexa discovery, add a + # fake preset (see issue #53832). + if len(preset_modes) == 1: + self._resource.add_mode( + f"{fan.ATTR_PRESET_MODE}.{PRESET_MODE_NA}", [PRESET_MODE_NA] + ) return self._resource.serialize_capability_resources() # Cover Position Resources @@ -1483,16 +1490,6 @@ class AlexaRangeController(AlexaCapability): if self.entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): return None - # Fan Speed - if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes.get(fan.ATTR_SPEED_LIST) - speed = self.entity.attributes.get(fan.ATTR_SPEED) - if speed_list is not None and speed is not None: - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index - # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) @@ -1501,6 +1498,13 @@ class AlexaRangeController(AlexaCapability): if self.instance == f"{cover.DOMAIN}.tilt": return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) + # Fan speed percentage + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported and fan.SUPPORT_SET_SPEED: + return self.entity.attributes.get(fan.ATTR_PERCENTAGE) + return 100 if self.entity.state == fan.STATE_ON else 0 + # Input Number Value if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": return float(self.entity.state) @@ -1527,28 +1531,16 @@ class AlexaRangeController(AlexaCapability): def capability_resources(self): """Return capabilityResources object.""" - # Fan Speed Resources - if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes[fan.ATTR_SPEED_LIST] - max_value = len(speed_list) - 1 + # Fan Speed Percentage Resources + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = self.entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) self._resource = AlexaPresetResource( - labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + labels=["Percentage", AlexaGlobalCatalog.SETTING_FAN_SPEED], min_value=0, - max_value=max_value, - precision=1, + max_value=100, + precision=percentage_step if percentage_step else 100, + unit=AlexaGlobalCatalog.UNIT_PERCENT, ) - for index, speed in enumerate(speed_list): - labels = [] - if isinstance(speed, str): - labels.append(speed.replace("_", " ")) - if index == 1: - labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) - if index == max_value: - labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) - - if len(labels) > 0: - self._resource.add_preset(value=index, labels=labels) - return self._resource.serialize_capability_resources() # Cover Position Resources @@ -1661,6 +1653,20 @@ class AlexaRangeController(AlexaCapability): ) return self._semantics.serialize_semantics() + # Fan Speed Percentage + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] + self._semantics = AlexaSemantics() + + self._semantics.add_action_to_directive( + lower_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + raise_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index de8a4a6fdc4..0532c85dac1 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -78,6 +78,9 @@ API_THERMOSTAT_MODES = OrderedDict( API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"} API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} +# AlexaModeController does not like a single mode for the fan preset, we add PRESET_MODE_NA if a fan has only one preset_mode +PRESET_MODE_NA = "-" + class Cause: """Possible causes for property changes. diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index cef18623bf5..d74f9329812 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -60,11 +60,9 @@ from .capabilities import ( AlexaLockController, AlexaModeController, AlexaMotionSensor, - AlexaPercentageController, AlexaPlaybackController, AlexaPlaybackStateReporter, AlexaPowerController, - AlexaPowerLevelController, AlexaRangeController, AlexaSceneController, AlexaSecurityPanelController, @@ -530,27 +528,32 @@ class FanCapabilities(AlexaEntity): def interfaces(self): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - + force_range_controller = True supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & fan.SUPPORT_SET_SPEED: - yield AlexaPercentageController(self.entity) - yield AlexaPowerLevelController(self.entity) - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - yield AlexaRangeController( - self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}" - ) if supported & fan.SUPPORT_OSCILLATE: yield AlexaToggleController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) + force_range_controller = False if supported & fan.SUPPORT_PRESET_MODE: yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" ) + force_range_controller = False if supported & fan.SUPPORT_DIRECTION: yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" ) + force_range_controller = False + + # AlexaRangeController controls the Fan Speed Percentage. + # For fans which only support on/off, no controller is added. This makes the + # fan impossible to turn on or off through Alexa, most likely due to a bug in Alexa. + # As a workaround, we add a range controller which can only be set to 0% or 100%. + if force_range_controller or supported & fan.SUPPORT_SET_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}" + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.hass) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 01d1369eb2f..5a23f5d1bc2 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -54,6 +54,8 @@ from .const import ( API_THERMOSTAT_MODES, API_THERMOSTAT_MODES_CUSTOM, API_THERMOSTAT_PRESETS, + DATE_FORMAT, + PRESET_MODE_NA, Cause, Inputs, ) @@ -122,6 +124,8 @@ async def async_api_turn_on(hass, config, directive, context): service = SERVICE_TURN_ON if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_ON elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START: @@ -156,6 +160,8 @@ async def async_api_turn_off(hass, config, directive, context): service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_OFF elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -318,7 +324,7 @@ async def async_api_activate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } return directive.response( @@ -342,7 +348,7 @@ async def async_api_deactivate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } return directive.response( @@ -825,48 +831,6 @@ async def async_api_reportstate(hass, config, directive, context): return directive.response(name="StateReport") -@HANDLERS.register(("Alexa.PowerLevelController", "SetPowerLevel")) -async def async_api_set_power_level(hass, config, directive, context): - """Process a SetPowerLevel request.""" - entity = directive.entity - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_PERCENTAGE - percentage = int(directive.payload["powerLevel"]) - data[fan.ATTR_PERCENTAGE] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - - return directive.response() - - -@HANDLERS.register(("Alexa.PowerLevelController", "AdjustPowerLevel")) -async def async_api_adjust_power_level(hass, config, directive, context): - """Process an AdjustPowerLevel request.""" - entity = directive.entity - percentage_delta = int(directive.payload["powerLevelDelta"]) - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_PERCENTAGE - current = entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - - # set percentage - percentage = min(100, max(0, percentage_delta + current)) - data[fan.ATTR_PERCENTAGE] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - - return directive.response() - - @HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) async def async_api_arm(hass, config, directive, context): """Process a Security Panel Arm request.""" @@ -961,7 +925,9 @@ async def async_api_set_mode(hass, config, directive, context): # Fan preset_mode elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": preset_mode = mode.split(".")[1] - if preset_mode in entity.attributes.get(fan.ATTR_PRESET_MODES): + if preset_mode != PRESET_MODE_NA and preset_mode in entity.attributes.get( + fan.ATTR_PRESET_MODES + ): service = fan.SERVICE_SET_PRESET_MODE data[fan.ATTR_PRESET_MODE] = preset_mode else: @@ -1091,24 +1057,8 @@ async def async_api_set_range(hass, config, directive, context): data = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] - # Fan Speed - if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - range_value = int(range_value) - service = fan.SERVICE_SET_SPEED - speed_list = entity.attributes[fan.ATTR_SPEED_LIST] - speed = next((v for i, v in enumerate(speed_list) if i == range_value), None) - - if not speed: - msg = "Entity does not support value" - raise AlexaInvalidValueError(msg) - - if speed == fan.SPEED_OFF: - service = fan.SERVICE_TURN_OFF - - data[fan.ATTR_SPEED] = speed - # Cover Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) if range_value == 0: service = cover.SERVICE_CLOSE_COVER @@ -1129,6 +1079,19 @@ async def async_api_set_range(hass, config, directive, context): service = cover.SERVICE_SET_COVER_TILT_POSITION data[cover.ATTR_TILT_POSITION] = range_value + # Fan Speed + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + range_value = int(range_value) + if range_value == 0: + service = fan.SERVICE_TURN_OFF + else: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported and fan.SUPPORT_SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value + else: + service = fan.SERVICE_TURN_ON + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_value = float(range_value) @@ -1184,29 +1147,8 @@ async def async_api_adjust_range(hass, config, directive, context): range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) response_value = 0 - # Fan Speed - if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - range_delta = int(range_delta) - service = fan.SERVICE_SET_SPEED - speed_list = entity.attributes[fan.ATTR_SPEED_LIST] - current_speed = entity.attributes[fan.ATTR_SPEED] - current_speed_index = next( - (i for i, v in enumerate(speed_list) if v == current_speed), 0 - ) - new_speed_index = min( - len(speed_list) - 1, max(0, current_speed_index + range_delta) - ) - speed = next( - (v for i, v in enumerate(speed_list) if i == new_speed_index), None - ) - - if speed == fan.SPEED_OFF: - service = fan.SERVICE_TURN_OFF - - data[fan.ATTR_SPEED] = response_value = speed - # Cover Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) service = SERVICE_SET_COVER_POSITION current = entity.attributes.get(cover.ATTR_POSITION) @@ -1237,6 +1179,25 @@ async def async_api_adjust_range(hass, config, directive, context): else: data[cover.ATTR_TILT_POSITION] = tilt_position + # Fan speed percentage + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20 + range_delta = ( + int(range_delta * percentage_step) + if range_delta_default + else int(range_delta) + ) + service = fan.SERVICE_SET_PERCENTAGE + current = entity.attributes.get(fan.ATTR_PERCENTAGE) + if not current: + msg = f"Unable to determine {entity.entity_id} current fan speed" + raise AlexaInvalidValueError(msg) + percentage = response_value = min(100, max(0, range_delta + current)) + if percentage: + data[fan.ATTR_PERCENTAGE] = percentage + else: + service = fan.SERVICE_TURN_OFF + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_delta = float(range_delta) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 712a08ac6b9..7a23706b4ba 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util -from .const import API_CHANGE, DOMAIN, Cause +from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .messages import AlexaResponse @@ -252,7 +252,7 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): namespace="Alexa.DoorbellEventSource", payload={ "cause": {"type": Cause.PHYSICAL_INTERACTION}, - "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), }, ) diff --git a/homeassistant/components/almond/translations/fr.json b/homeassistant/components/almond/translations/fr.json index 0e6a8e0be3f..a464e8b56e9 100644 --- a/homeassistant/components/almond/translations/fr.json +++ b/homeassistant/components/almond/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "cannot_connect": "Impossible de se connecter au serveur Almond", - "missing_configuration": "Veuillez consulter la documentation pour savoir comment configurer Almond.", + "cannot_connect": "\u00c9chec de connexion", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, diff --git a/homeassistant/components/almond/translations/hu.json b/homeassistant/components/almond/translations/hu.json index 568cd7270de..d75290b4fd1 100644 --- a/homeassistant/components/almond/translations/hu.json +++ b/homeassistant/components/almond/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, 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.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant alkalmaz\u00e1st az Almondhoz val\u00f3 csatlakoz\u00e1shoz, amelyet a Supervisor kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", - "title": "Almond a Supervisor kieg\u00e9sz\u00edt\u0151n kereszt\u00fcl" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot Almondhoz val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "title": "Almond - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" diff --git a/homeassistant/components/almond/translations/id.json b/homeassistant/components/almond/translations/id.json index 21a627132c4..8e4302220b5 100644 --- a/homeassistant/components/almond/translations/id.json +++ b/homeassistant/components/almond/translations/id.json @@ -8,7 +8,7 @@ }, "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke Almond yang disediakan oleh add-on Supervisor {addon}?", + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke Almond yang disediakan oleh add-on: {addon}?", "title": "Almond melalui add-on Home Assistant" }, "pick_implementation": { diff --git a/homeassistant/components/ambee/translations/es.json b/homeassistant/components/ambee/translations/es.json index de5ce971fa0..7f4f8b75de5 100644 --- a/homeassistant/components/ambee/translations/es.json +++ b/homeassistant/components/ambee/translations/es.json @@ -1,12 +1,26 @@ { "config": { + "abort": { + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave API no v\u00e1lida" + }, "step": { "reauth_confirm": { "data": { + "api_key": "Clave API", "description": "Vuelva a autenticarse con su cuenta de Ambee." } }, "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, "description": "Configure Ambee para que se integre con Home Assistant." } } diff --git a/homeassistant/components/ambee/translations/fr.json b/homeassistant/components/ambee/translations/fr.json index bbb09edf763..dc968329b49 100644 --- a/homeassistant/components/ambee/translations/fr.json +++ b/homeassistant/components/ambee/translations/fr.json @@ -1,22 +1,22 @@ { "config": { "abort": { - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_api_key": "Cl\u00e9 API non valide" + "invalid_api_key": "Cl\u00e9 API invalide" }, "step": { "reauth_confirm": { "data": { - "api_key": "cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "description": "R\u00e9-authentifiez-vous avec votre compte Ambee." } }, "user": { "data": { - "api_key": "cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude", "name": "Nom" diff --git a/homeassistant/components/ambee/translations/hu.json b/homeassistant/components/ambee/translations/hu.json index 4cf99c596f0..6cb59bba925 100644 --- a/homeassistant/components/ambee/translations/hu.json +++ b/homeassistant/components/ambee/translations/hu.json @@ -21,7 +21,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, - "description": "\u00c1ll\u00edtsa be az Ambee-t a Homeassistanttal val\u00f3 integr\u00e1ci\u00f3hoz." + "description": "Integr\u00e1lja \u00f6ssze Ambeet Home Assistanttal." } } } diff --git a/homeassistant/components/ambee/translations/id.json b/homeassistant/components/ambee/translations/id.json index ecf627579fe..a5790d95ecd 100644 --- a/homeassistant/components/ambee/translations/id.json +++ b/homeassistant/components/ambee/translations/id.json @@ -10,7 +10,8 @@ "step": { "reauth_confirm": { "data": { - "api_key": "Kunci API" + "api_key": "Kunci API", + "description": "Autentikasi ulang dengan akun Ambee Anda." } }, "user": { @@ -19,7 +20,8 @@ "latitude": "Lintang", "longitude": "Bujur", "name": "Nama" - } + }, + "description": "Siapkan Ambee Anda untuk diintegrasikan dengan Home Assistant." } } } diff --git a/homeassistant/components/ambee/translations/sensor.id.json b/homeassistant/components/ambee/translations/sensor.id.json new file mode 100644 index 00000000000..61bdea468ee --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "ambee__risk": { + "high": "Tinggi", + "low": "Rendah", + "moderate": "Sedang" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.pl.json b/homeassistant/components/ambee/translations/sensor.pl.json index 64d04cced48..d67bdec0879 100644 --- a/homeassistant/components/ambee/translations/sensor.pl.json +++ b/homeassistant/components/ambee/translations/sensor.pl.json @@ -1,10 +1,10 @@ { "state": { "ambee__risk": { - "high": "Wysoki", - "low": "Niski", - "moderate": "Umiarkowany", - "very high": "Bardzo wysoki" + "high": "wysoki", + "low": "niski", + "moderate": "umiarkowany", + "very high": "bardzo wysoki" } } } \ No newline at end of file diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py new file mode 100644 index 00000000000..0d39077f2f1 --- /dev/null +++ b/homeassistant/components/amberelectric/__init__.py @@ -0,0 +1,32 @@ +"""Support for Amber Electric.""" + +from amberelectric import Configuration +from amberelectric.api import amber_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, PLATFORMS +from .coordinator import AmberUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Amber Electric from a config entry.""" + configuration = Configuration(access_token=entry.data[CONF_API_TOKEN]) + api_instance = amber_api.AmberApi.create(configuration) + site_id = entry.data[CONF_SITE_ID] + + coordinator = AmberUpdateCoordinator(hass, api_instance, site_id) + 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.""" + 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/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py new file mode 100644 index 00000000000..aff19c6f695 --- /dev/null +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -0,0 +1,88 @@ +"""Amber Electric Binary Sensor definitions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import AmberUpdateCoordinator + +PRICE_SPIKE_ICONS = { + "none": "mdi:power-plug", + "potential": "mdi:power-plug-outline", + "spike": "mdi:power-plug-off", +} + + +class AmberPriceGridSensor(CoordinatorEntity, BinarySensorEntity): + """Sensor to show single grid binary values.""" + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self._attr_unique_id = f"{coordinator.site_id}-{description.key}" + self._attr_device_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.data["grid"][self.entity_description.key] + + +class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): + """Sensor to show single grid binary values.""" + + @property + def icon(self): + """Return the sensor icon.""" + status = self.coordinator.data["grid"]["price_spike"] + return PRICE_SPIKE_ICONS[status] + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.data["grid"]["price_spike"] == "spike" + + @property + def device_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price spike.""" + + spike_status = self.coordinator.data["grid"]["price_spike"] + return { + "spike_status": spike_status, + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list = [] + price_spike_description = BinarySensorEntityDescription( + key="price_spike", + name=f"{entry.title} - Price Spike", + ) + entities.append(AmberPriceSpikeBinarySensor(coordinator, price_spike_description)) + async_add_entities(entities) diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py new file mode 100644 index 00000000000..efb5ddfb931 --- /dev/null +++ b/homeassistant/components/amberelectric/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for the Amber Electric integration.""" +from __future__ import annotations + +from typing import Any + +import amberelectric +from amberelectric.api import amber_api +from amberelectric.model.site import Site +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN + +from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN + +API_URL = "https://app.amber.com.au/developers" + + +class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors: dict[str, str] = {} + self._sites: list[Site] | None = None + self._api_token: str | None = None + + def _fetch_sites(self, token: str) -> list[Site] | None: + configuration = amberelectric.Configuration(access_token=token) + api = amber_api.AmberApi.create(configuration) + + try: + sites = api.get_sites() + if len(sites) == 0: + self._errors[CONF_API_TOKEN] = "no_site" + return None + return sites + except amberelectric.ApiException as api_exception: + if api_exception.status == 403: + self._errors[CONF_API_TOKEN] = "invalid_api_token" + else: + self._errors[CONF_API_TOKEN] = "unknown_error" + return None + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Step when user initializes a integration.""" + self._errors = {} + self._sites = None + self._api_token = None + + if user_input is not None: + token = user_input[CONF_API_TOKEN] + self._sites = await self.hass.async_add_executor_job( + self._fetch_sites, token + ) + + if self._sites is not None: + self._api_token = token + return await self.async_step_site() + + else: + user_input = {CONF_API_TOKEN: ""} + + return self.async_show_form( + step_id="user", + description_placeholders={"api_url": API_URL}, + data_schema=vol.Schema( + { + vol.Required( + CONF_API_TOKEN, default=user_input[CONF_API_TOKEN] + ): str, + } + ), + errors=self._errors, + ) + + async def async_step_site(self, user_input: dict[str, Any] = None): + """Step to select site.""" + self._errors = {} + + assert self._sites is not None + + api_token = self._api_token + if user_input is not None: + site_nmi = user_input[CONF_SITE_NMI] + sites = [site for site in self._sites if site.nmi == site_nmi] + site = sites[0] + site_id = site.id + name = user_input.get(CONF_SITE_NAME, site_id) + return self.async_create_entry( + title=name, + data={ + CONF_SITE_ID: site_id, + CONF_API_TOKEN: api_token, + CONF_SITE_NMI: site.nmi, + }, + ) + + user_input = { + CONF_API_TOKEN: api_token, + CONF_SITE_NMI: "", + CONF_SITE_NAME: "", + } + + return self.async_show_form( + step_id="site", + data_schema=vol.Schema( + { + vol.Required( + CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] + ): vol.In([site.nmi for site in self._sites]), + vol.Optional( + CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] + ): str, + } + ), + errors=self._errors, + ) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py new file mode 100644 index 00000000000..fe2e5f9bb88 --- /dev/null +++ b/homeassistant/components/amberelectric/const.py @@ -0,0 +1,13 @@ +"""Amber Electric Constants.""" +import logging + +DOMAIN = "amberelectric" +CONF_API_TOKEN = "api_token" +CONF_SITE_NAME = "site_name" +CONF_SITE_ID = "site_id" +CONF_SITE_NMI = "site_nmi" + +ATTRIBUTION = "Data provided by Amber Electric" + +LOGGER = logging.getLogger(__package__) +PLATFORMS = ["sensor", "binary_sensor"] diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py new file mode 100644 index 00000000000..904da59f65c --- /dev/null +++ b/homeassistant/components/amberelectric/coordinator.py @@ -0,0 +1,111 @@ +"""Amber Electric Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from amberelectric import ApiException +from amberelectric.api import amber_api +from amberelectric.model.actual_interval import ActualInterval +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +def is_current(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is a CurrentInterval.""" + return isinstance(interval, CurrentInterval) + + +def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is a ForecastInterval.""" + return isinstance(interval, ForecastInterval) + + +def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is on the general channel.""" + return interval.channel_type == ChannelType.GENERAL + + +def is_controlled_load( + interval: ActualInterval | CurrentInterval | ForecastInterval, +) -> bool: + """Return true if the supplied interval is on the controlled load channel.""" + return interval.channel_type == ChannelType.CONTROLLED_LOAD + + +def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: + """Return true if the supplied interval is on the feed in channel.""" + return interval.channel_type == ChannelType.FEED_IN + + +class AmberUpdateCoordinator(DataUpdateCoordinator): + """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" + + def __init__( + self, hass: HomeAssistant, api: amber_api.AmberApi, site_id: str + ) -> None: + """Initialise the data service.""" + super().__init__( + hass, + LOGGER, + name="amberelectric", + update_interval=timedelta(minutes=1), + ) + self._api = api + self.site_id = site_id + + def update_price_data(self) -> dict[str, dict[str, Any]]: + """Update callback.""" + + result: dict[str, dict[str, Any]] = { + "current": {}, + "forecasts": {}, + "grid": {}, + } + try: + data = self._api.get_current_price(self.site_id, next=48) + except ApiException as api_exception: + raise UpdateFailed("Missing price data, skipping update") from api_exception + + current = [interval for interval in data if is_current(interval)] + forecasts = [interval for interval in data if is_forecast(interval)] + general = [interval for interval in current if is_general(interval)] + + if len(general) == 0: + raise UpdateFailed("No general channel configured") + + result["current"]["general"] = general[0] + result["forecasts"]["general"] = [ + interval for interval in forecasts if is_general(interval) + ] + result["grid"]["renewables"] = round(general[0].renewables) + result["grid"]["price_spike"] = general[0].spike_status.value + + controlled_load = [ + interval for interval in current if is_controlled_load(interval) + ] + if controlled_load: + result["current"]["controlled_load"] = controlled_load[0] + result["forecasts"]["controlled_load"] = [ + interval for interval in forecasts if is_controlled_load(interval) + ] + + feed_in = [interval for interval in current if is_feed_in(interval)] + if feed_in: + result["current"]["feed_in"] = feed_in[0] + result["forecasts"]["feed_in"] = [ + interval for interval in forecasts if is_feed_in(interval) + ] + + LOGGER.debug("Fetched new Amber data: %s", data) + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Async update wrapper.""" + return await self.hass.async_add_executor_job(self.update_price_data) diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json new file mode 100644 index 00000000000..6dc79513e55 --- /dev/null +++ b/homeassistant/components/amberelectric/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "amberelectric", + "name": "Amber Electric", + "documentation": "https://www.home-assistant.io/integrations/amberelectric", + "config_flow": true, + "codeowners": [ + "@madpilot" + ], + "requirements": [ + "amberelectric==1.0.3" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py new file mode 100644 index 00000000000..974de2d5c15 --- /dev/null +++ b/homeassistant/components/amberelectric/sensor.py @@ -0,0 +1,234 @@ +"""Amber Electric Sensor definitions.""" + +# There are three types of sensor: Current, Forecast and Grid +# Current and forecast will create general, controlled load and feed in as required +# At the moment renewables in the only grid sensor. + + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CURRENCY_DOLLAR, ENERGY_KILO_WATT_HOUR +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import AmberUpdateCoordinator + +ICONS = { + "general": "mdi:transmission-tower", + "controlled_load": "mdi:clock-outline", + "feed_in": "mdi:solar-power", +} + +UNIT = f"{CURRENCY_DOLLAR}/{ENERGY_KILO_WATT_HOUR}" + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) + + +def friendly_channel_type(channel_type: str) -> str: + """Return a human readable version of the channel type.""" + if channel_type == "controlled_load": + return "Controlled Load" + if channel_type == "feed_in": + return "Feed In" + return "General" + + +class AmberSensor(CoordinatorEntity, SensorEntity): + """Amber Base Sensor.""" + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: SensorEntityDescription, + channel_type: ChannelType, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self.channel_type = channel_type + + self._attr_unique_id = ( + f"{self.site_id}-{self.entity_description.key}-{self.channel_type}" + ) + + +class AmberPriceSensor(AmberSensor): + """Amber Price Sensor.""" + + @property + def native_value(self) -> float | None: + """Return the current price in $/kWh.""" + interval = self.coordinator.data[self.entity_description.key][self.channel_type] + + if interval.channel_type == ChannelType.FEED_IN: + return format_cents_to_dollars(interval.per_kwh) * -1 + return format_cents_to_dollars(interval.per_kwh) + + @property + def device_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price.""" + interval = self.coordinator.data[self.entity_description.key][self.channel_type] + + data: dict[str, Any] = {ATTR_ATTRIBUTION: ATTRIBUTION} + if interval is None: + return data + + data["duration"] = interval.duration + data["date"] = interval.date.isoformat() + data["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEED_IN: + data["per_kwh"] = data["per_kwh"] * -1 + data["nem_date"] = interval.nem_time.isoformat() + data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + data["start_time"] = interval.start_time.isoformat() + data["end_time"] = interval.end_time.isoformat() + data["renewables"] = round(interval.renewables) + data["estimate"] = interval.estimate + data["spike_status"] = interval.spike_status.value + data["channel_type"] = interval.channel_type.value + + if interval.range is not None: + data["range_min"] = format_cents_to_dollars(interval.range.min) + data["range_max"] = format_cents_to_dollars(interval.range.max) + + return data + + +class AmberForecastSensor(AmberSensor): + """Amber Forecast Sensor.""" + + @property + def native_value(self) -> float | None: + """Return the first forecast price in $/kWh.""" + intervals = self.coordinator.data[self.entity_description.key].get( + self.channel_type + ) + if not intervals: + return None + interval = intervals[0] + + if interval.channel_type == ChannelType.FEED_IN: + return format_cents_to_dollars(interval.per_kwh) * -1 + return format_cents_to_dollars(interval.per_kwh) + + @property + def device_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional pieces of information about the price.""" + intervals = self.coordinator.data[self.entity_description.key].get( + self.channel_type + ) + + if not intervals: + return None + + data = { + "forecasts": [], + "channel_type": intervals[0].channel_type.value, + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEED_IN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + data["forecasts"].append(datum) + + return data + + +class AmberGridSensor(CoordinatorEntity, SensorEntity): + """Sensor to show single grid specific values.""" + + def __init__( + self, + coordinator: AmberUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + self.site_id = coordinator.site_id + self.entity_description = description + self._attr_device_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_unique_id = f"{coordinator.site_id}-{description.key}" + + @property + def native_value(self) -> str | None: + """Return the value of the sensor.""" + return self.coordinator.data["grid"][self.entity_description.key] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + current: dict[str, CurrentInterval] = coordinator.data["current"] + forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"] + + entities: list = [] + for channel_type in current: + description = SensorEntityDescription( + key="current", + name=f"{entry.title} - {friendly_channel_type(channel_type)} Price", + native_unit_of_measurement=UNIT, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICONS[channel_type], + ) + entities.append(AmberPriceSensor(coordinator, description, channel_type)) + + for channel_type in forecasts: + description = SensorEntityDescription( + key="forecasts", + name=f"{entry.title} - {friendly_channel_type(channel_type)} Forecast", + native_unit_of_measurement=UNIT, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICONS[channel_type], + ) + entities.append(AmberForecastSensor(coordinator, description, channel_type)) + + renewables_description = SensorEntityDescription( + key="renewables", + name=f"{entry.title} - Renewables", + native_unit_of_measurement="%", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:solar-power", + ) + entities.append(AmberGridSensor(coordinator, renewables_description)) + + async_add_entities(entities) diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json new file mode 100644 index 00000000000..cdbff2022b3 --- /dev/null +++ b/homeassistant/components/amberelectric/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "title": "Amber Electric", + "description": "Go to {api_url} to generate an API key" + }, + "site": { + "data": { + "site_nmi": "Site NMI", + "site_name": "Site Name" + }, + "title": "Amber Electric", + "description": "Select the NMI of the site you would like to add" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ca.json b/homeassistant/components/amberelectric/translations/ca.json new file mode 100644 index 00000000000..cf9bca64df6 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nom del lloc", + "site_nmi": "NMI del lloc" + }, + "description": "Selecciona l'NMI del lloc que vulguis afegir", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token d'API", + "site_id": "ID del lloc" + }, + "description": "Ves a {api_url} per generar una clau API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/de.json b/homeassistant/components/amberelectric/translations/de.json new file mode 100644 index 00000000000..2143795f479 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Name des Standorts", + "site_nmi": "Standort NMI" + }, + "description": "W\u00e4hle die NMI des Standorts, den du hinzuf\u00fcgen m\u00f6chtest", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API-Token", + "site_id": "Site-ID" + }, + "description": "Gehe zu {api_url}, um einen API-Schl\u00fcssel zu generieren", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/en.json b/homeassistant/components/amberelectric/translations/en.json new file mode 100644 index 00000000000..60c7caae456 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Site Name", + "site_nmi": "Site NMI" + }, + "description": "Select the NMI of the site you would like to add", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "description": "Go to {api_url} to generate an API key", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/et.json b/homeassistant/components/amberelectric/translations/et.json new file mode 100644 index 00000000000..05a7e6c6dc2 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Saidi nimi", + "site_nmi": "Saidi NMI" + }, + "description": "Vali lisatava saidi NMI", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API v\u00f5ti", + "site_id": "Saidi ID" + }, + "description": "API-v\u00f5tme saamiseks ava {api_url}.", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/hu.json b/homeassistant/components/amberelectric/translations/hu.json new file mode 100644 index 00000000000..9811f5a5f8f --- /dev/null +++ b/homeassistant/components/amberelectric/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Hely neve", + "site_nmi": "Hely NMI" + }, + "description": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt hely NMI-j\u00e9t.", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Hely ID" + }, + "description": "API-kulcs gener\u00e1l\u00e1s\u00e1hoz l\u00e1togasson el ide: {api_url}", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/it.json b/homeassistant/components/amberelectric/translations/it.json new file mode 100644 index 00000000000..5b061561954 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nome del sito", + "site_nmi": "Sito NMI" + }, + "description": "Seleziona l'NMI del sito che desideri aggiungere", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token API", + "site_id": "ID sito" + }, + "description": "Vai su {api_url} per generare una chiave API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/nl.json b/homeassistant/components/amberelectric/translations/nl.json new file mode 100644 index 00000000000..a874c12f283 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Sitenaam", + "site_nmi": "Site NMI" + }, + "description": "Selecteer de NMI van de site die u wilt toevoegen", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Token", + "site_id": "Site ID" + }, + "description": "Ga naar {api_url} om een API sleutel aan te maken", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/no.json b/homeassistant/components/amberelectric/translations/no.json new file mode 100644 index 00000000000..90d4bd930b9 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Side navn", + "site_nmi": "Nettsted NMI" + }, + "description": "Velg NMI for nettstedet du vil legge til", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API-token", + "site_id": "Nettsted -ID" + }, + "description": "G\u00e5 til {api_url} \u00e5 generere en API -n\u00f8kkel", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ru.json b/homeassistant/components/amberelectric/translations/ru.json new file mode 100644 index 00000000000..4b8caee72ee --- /dev/null +++ b/homeassistant/components/amberelectric/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0447\u0430\u0441\u0442\u043a\u0430", + "site_nmi": "NMI \u0443\u0447\u0430\u0441\u0442\u043a\u0430" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 NMI \u0443\u0447\u0430\u0441\u0442\u043a\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "\u0422\u043e\u043a\u0435\u043d API", + "site_id": "ID \u0443\u0447\u0430\u0441\u0442\u043a\u0430" + }, + "description": "\u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 {api_url} \u0447\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API.", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/zh-Hant.json b/homeassistant/components/amberelectric/translations/zh-Hant.json new file mode 100644 index 00000000000..0af0e5e60bb --- /dev/null +++ b/homeassistant/components/amberelectric/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "\u4f4d\u5740\u540d\u7a31", + "site_nmi": "\u4f4d\u5740 NMI" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u4f4d\u5740 NMI", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API \u6b0a\u6756", + "site_id": "\u4f4d\u5740 ID" + }, + "description": "\u9023\u7dda\u81f3 {api_url} \u4ee5\u7522\u751f API \u5bc6\u9470", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 2643b01185a..04d1b749d10 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -142,6 +142,7 @@ class AmbiclimateAuthCallbackView(HomeAssistantView): async def get(self, request: web.Request) -> str: """Receive authorization token.""" + # pylint: disable=no-self-use code = request.query.get("code") if code is None: return "No code" diff --git a/homeassistant/components/ambiclimate/translations/ca.json b/homeassistant/components/ambiclimate/translations/ca.json index 8e54a222217..234cb1a413c 100644 --- a/homeassistant/components/ambiclimate/translations/ca.json +++ b/homeassistant/components/ambiclimate/translations/ca.json @@ -2,7 +2,7 @@ "config": { "abort": { "access_token": "S'ha produ\u00eft un error desconegut al generat un token d'acc\u00e9s.", - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3." }, "create_entry": { diff --git a/homeassistant/components/ambiclimate/translations/fr.json b/homeassistant/components/ambiclimate/translations/fr.json index 37ef9549686..b6464b58244 100644 --- a/homeassistant/components/ambiclimate/translations/fr.json +++ b/homeassistant/components/ambiclimate/translations/fr.json @@ -6,7 +6,7 @@ "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." }, "create_entry": { - "default": "Authentifi\u00e9 avec succ\u00e8s avec Ambiclimate" + "default": "Authentification r\u00e9ussie" }, "error": { "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.", diff --git a/homeassistant/components/ambiclimate/translations/hu.json b/homeassistant/components/ambiclimate/translations/hu.json index 3898535c427..597645658d8 100644 --- a/homeassistant/components/ambiclimate/translations/hu.json +++ b/homeassistant/components/ambiclimate/translations/hu.json @@ -3,18 +3,18 @@ "abort": { "access_token": "Ismeretlen hiba a hozz\u00e1f\u00e9r\u00e9si token gener\u00e1l\u00e1s\u00e1ban.", "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" }, "error": { - "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", "no_token": "Nem hiteles\u00edtett Ambiclimate" }, "step": { "auth": { - "description": "K\u00e9rj\u00fck, k\u00f6vesse ezt a [link] ({authorization_url} Author_url}) \u00e9s ** Enged\u00e9lyezze ** a hozz\u00e1f\u00e9r\u00e9st Ambiclimate -fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot.\n (Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott visszah\u00edv\u00e1si URL {cb_url})", + "description": "K\u00e9rj\u00fck, k\u00f6vesse ezt a [link]({authorization_url}}) \u00e9s ** Enged\u00e9lyezze ** a hozz\u00e1f\u00e9r\u00e9st Ambiclimate -fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot.\n(Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott visszah\u00edv\u00e1si URL {cb_url})", "title": "Ambiclimate hiteles\u00edt\u00e9se" } } diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 68b8579f731..1f1b21b4346 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -1,38 +1,17 @@ """Support for Ambient Weather Station Service.""" from __future__ import annotations +from typing import Any + from aioambient import Client from aioambient.errors import WebsocketError -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 ConfigEntry from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, - DEGREE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, EVENT_HOMEASSISTANT_STOP, - 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 Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -41,266 +20,43 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import async_call_later from .const import ( ATTR_LAST_DATA, - ATTR_MONITORED_CONDITIONS, CONF_APP_KEY, DATA_CLIENT, DOMAIN, LOGGER, + TYPE_SOLARRADIATION, + TYPE_SOLARRADIATION_LX, ) -PLATFORMS = [BINARY_SENSOR, SENSOR] +PLATFORMS = ["binary_sensor", "sensor"] DATA_CONFIG = "config" DEFAULT_SOCKET_MIN_RETRY = 15 -TYPE_24HOURRAININ = "24hourrainin" -TYPE_BAROMABSIN = "baromabsin" -TYPE_BAROMRELIN = "baromrelin" -TYPE_BATT1 = "batt1" -TYPE_BATT10 = "batt10" -TYPE_BATT2 = "batt2" -TYPE_BATT3 = "batt3" -TYPE_BATT4 = "batt4" -TYPE_BATT5 = "batt5" -TYPE_BATT6 = "batt6" -TYPE_BATT7 = "batt7" -TYPE_BATT8 = "batt8" -TYPE_BATT9 = "batt9" -TYPE_BATT_CO2 = "batt_co2" -TYPE_BATTOUT = "battout" -TYPE_CO2 = "co2" -TYPE_DAILYRAININ = "dailyrainin" -TYPE_DEWPOINT = "dewPoint" -TYPE_EVENTRAININ = "eventrainin" -TYPE_FEELSLIKE = "feelsLike" -TYPE_HOURLYRAININ = "hourlyrainin" -TYPE_HUMIDITY = "humidity" -TYPE_HUMIDITY1 = "humidity1" -TYPE_HUMIDITY10 = "humidity10" -TYPE_HUMIDITY2 = "humidity2" -TYPE_HUMIDITY3 = "humidity3" -TYPE_HUMIDITY4 = "humidity4" -TYPE_HUMIDITY5 = "humidity5" -TYPE_HUMIDITY6 = "humidity6" -TYPE_HUMIDITY7 = "humidity7" -TYPE_HUMIDITY8 = "humidity8" -TYPE_HUMIDITY9 = "humidity9" -TYPE_HUMIDITYIN = "humidityin" -TYPE_LASTRAIN = "lastRain" -TYPE_MAXDAILYGUST = "maxdailygust" -TYPE_MONTHLYRAININ = "monthlyrainin" -TYPE_PM25 = "pm25" -TYPE_PM25_24H = "pm25_24h" -TYPE_PM25_BATT = "batt_25" -TYPE_PM25_IN = "pm25_in" -TYPE_PM25_IN_24H = "pm25_in_24h" -TYPE_PM25IN_BATT = "batt_25in" -TYPE_RELAY1 = "relay1" -TYPE_RELAY10 = "relay10" -TYPE_RELAY2 = "relay2" -TYPE_RELAY3 = "relay3" -TYPE_RELAY4 = "relay4" -TYPE_RELAY5 = "relay5" -TYPE_RELAY6 = "relay6" -TYPE_RELAY7 = "relay7" -TYPE_RELAY8 = "relay8" -TYPE_RELAY9 = "relay9" -TYPE_SOILHUM1 = "soilhum1" -TYPE_SOILHUM10 = "soilhum10" -TYPE_SOILHUM2 = "soilhum2" -TYPE_SOILHUM3 = "soilhum3" -TYPE_SOILHUM4 = "soilhum4" -TYPE_SOILHUM5 = "soilhum5" -TYPE_SOILHUM6 = "soilhum6" -TYPE_SOILHUM7 = "soilhum7" -TYPE_SOILHUM8 = "soilhum8" -TYPE_SOILHUM9 = "soilhum9" -TYPE_SOILTEMP1F = "soiltemp1f" -TYPE_SOILTEMP10F = "soiltemp10f" -TYPE_SOILTEMP2F = "soiltemp2f" -TYPE_SOILTEMP3F = "soiltemp3f" -TYPE_SOILTEMP4F = "soiltemp4f" -TYPE_SOILTEMP5F = "soiltemp5f" -TYPE_SOILTEMP6F = "soiltemp6f" -TYPE_SOILTEMP7F = "soiltemp7f" -TYPE_SOILTEMP8F = "soiltemp8f" -TYPE_SOILTEMP9F = "soiltemp9f" -TYPE_SOLARRADIATION = "solarradiation" -TYPE_SOLARRADIATION_LX = "solarradiation_lx" -TYPE_TEMP10F = "temp10f" -TYPE_TEMP1F = "temp1f" -TYPE_TEMP2F = "temp2f" -TYPE_TEMP3F = "temp3f" -TYPE_TEMP4F = "temp4f" -TYPE_TEMP5F = "temp5f" -TYPE_TEMP6F = "temp6f" -TYPE_TEMP7F = "temp7f" -TYPE_TEMP8F = "temp8f" -TYPE_TEMP9F = "temp9f" -TYPE_TEMPF = "tempf" -TYPE_TEMPINF = "tempinf" -TYPE_TOTALRAININ = "totalrainin" -TYPE_UV = "uv" -TYPE_WEEKLYRAININ = "weeklyrainin" -TYPE_WINDDIR = "winddir" -TYPE_WINDDIR_AVG10M = "winddir_avg10m" -TYPE_WINDDIR_AVG2M = "winddir_avg2m" -TYPE_WINDGUSTDIR = "windgustdir" -TYPE_WINDGUSTMPH = "windgustmph" -TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" -TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" -TYPE_WINDSPEEDMPH = "windspeedmph" -TYPE_YEARLYRAININ = "yearlyrainin" -SENSOR_TYPES = { - 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), - TYPE_BATT1: ("Battery 1", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT2: ("Battery 2", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT3: ("Battery 3", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT4: ("Battery 4", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT5: ("Battery 5", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT6: ("Battery 6", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT7: ("Battery 7", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT8: ("Battery 8", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_BATT9: ("Battery 9", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - 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", PRECIPITATION_INCHES, SENSOR, None), - TYPE_DEWPOINT: ("Dew Point", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_EVENTRAININ: ("Event Rain", PRECIPITATION_INCHES, SENSOR, None), - TYPE_FEELSLIKE: ("Feels Like", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - 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), - TYPE_HUMIDITY3: ("Humidity 3", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY4: ("Humidity 4", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY5: ("Humidity 5", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY6: ("Humidity 6", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY7: ("Humidity 7", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY8: ("Humidity 8", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY9: ("Humidity 9", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_HUMIDITY: ("Humidity", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - 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", PRECIPITATION_INCHES, SENSOR, None), - TYPE_PM25_24H: ( - "PM25 24h Avg", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25_BATT: ("PM25 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), - TYPE_PM25_IN: ( - "PM25 Indoor", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25_IN_24H: ( - "PM25 Indoor 24h Avg", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - SENSOR, - None, - ), - TYPE_PM25: ("PM25", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SENSOR, None), - TYPE_PM25IN_BATT: ( - "PM25 Indoor Battery", - None, - BINARY_SENSOR, - DEVICE_CLASS_BATTERY, - ), - TYPE_RELAY10: ("Relay 10", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY1: ("Relay 1", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY2: ("Relay 2", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY3: ("Relay 3", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY4: ("Relay 4", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY5: ("Relay 5", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY6: ("Relay 6", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY7: ("Relay 7", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY8: ("Relay 8", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_RELAY9: ("Relay 9", None, BINARY_SENSOR, DEVICE_CLASS_CONNECTIVITY), - TYPE_SOILHUM10: ("Soil Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM1: ("Soil Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM2: ("Soil Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM3: ("Soil Humidity 3", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM4: ("Soil Humidity 4", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM5: ("Soil Humidity 5", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM6: ("Soil Humidity 6", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM7: ("Soil Humidity 7", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM8: ("Soil Humidity 8", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILHUM9: ("Soil Humidity 9", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), - TYPE_SOILTEMP10F: ( - "Soil Temp 10", - TEMP_FAHRENHEIT, - SENSOR, - DEVICE_CLASS_TEMPERATURE, - ), - TYPE_SOILTEMP1F: ("Soil Temp 1", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP2F: ("Soil Temp 2", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP3F: ("Soil Temp 3", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP4F: ("Soil Temp 4", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP5F: ("Soil Temp 5", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP6F: ("Soil Temp 6", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP7F: ("Soil Temp 7", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP8F: ("Soil Temp 8", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOILTEMP9F: ("Soil Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_SOLARRADIATION: ( - "Solar Rad", - IRRADIATION_WATTS_PER_SQUARE_METER, - SENSOR, - None, - ), - TYPE_SOLARRADIATION_LX: ( - "Solar Rad (lx)", - LIGHT_LUX, - SENSOR, - DEVICE_CLASS_ILLUMINANCE, - ), - TYPE_TEMP10F: ("Temp 10", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP1F: ("Temp 1", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP2F: ("Temp 2", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP3F: ("Temp 3", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP4F: ("Temp 4", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP5F: ("Temp 5", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP6F: ("Temp 6", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP7F: ("Temp 7", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TEMP8F: ("Temp 8", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - 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", PRECIPITATION_INCHES, SENSOR, None), - TYPE_UV: ("uv", "Index", 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), - TYPE_WINDGUSTDIR: ("Gust Dir", DEGREE, SENSOR, None), - TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - 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", PRECIPITATION_INCHES, SENSOR, None), -} - CONFIG_SCHEMA = cv.deprecated(DOMAIN) +@callback +def async_wm2_to_lx(value: float) -> int: + """Calculate illuminance (in lux).""" + return round(value / 0.0079) + + +@callback +def async_hydrate_station_data(data: dict[str, Any]) -> dict[str, Any]: + """Hydrate station data with addition or normalized data.""" + if (irradiation := data.get(TYPE_SOLARRADIATION)) is not None: + data[TYPE_SOLARRADIATION_LX] = async_wm2_to_lx(irradiation) + + return data + + 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: {}}) @@ -406,13 +162,14 @@ class AmbientStation: 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]: - LOGGER.debug("New data received: %s", data) - self.stations[mac_address][ATTR_LAST_DATA] = data - async_dispatcher_send( - self._hass, f"ambient_station_data_update_{mac_address}" - ) + mac = data["macAddress"] + + if data == self.stations[mac][ATTR_LAST_DATA]: + return + + LOGGER.debug("New data received: %s", data) + self.stations[mac][ATTR_LAST_DATA] = async_hydrate_station_data(data) + async_dispatcher_send(self._hass, f"ambient_station_data_update_{mac}") def on_disconnect() -> None: """Define a handler to fire when the websocket is disconnected.""" @@ -421,26 +178,17 @@ class AmbientStation: 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: + if (mac := station["macAddress"]) in self.stations: continue + LOGGER.debug("New station subscription: %s", data) - # Only create entities based on the data coming through the socket. - # If the user is monitoring brightness (in W/m^2), make sure we also - # add a calculated sensor for the same data measured in lx: - monitored_conditions = [ - k for k in station["lastData"] if k in SENSOR_TYPES - ] - if TYPE_SOLARRADIATION in monitored_conditions: - monitored_conditions.append(TYPE_SOLARRADIATION_LX) - self.stations[station["macAddress"]] = { - ATTR_LAST_DATA: station["lastData"], + self.stations[mac] = { + ATTR_LAST_DATA: async_hydrate_station_data(station["lastData"]), ATTR_LOCATION: station.get("info", {}).get("location"), - ATTR_MONITORED_CONDITIONS: monitored_conditions, - ATTR_NAME: station.get("info", {}).get( - "name", station["macAddress"] - ), + ATTR_NAME: station.get("info", {}).get("name", mac), } + # If the websocket disconnects and reconnects, the on_subscribed # handler will get called again; in that case, we don't want to # attempt forward setup of the config entry (because it will have @@ -467,28 +215,26 @@ class AmbientStation: class AmbientWeatherEntity(Entity): """Define a base Ambient PWS entity.""" + _attr_should_poll = False + def __init__( self, ambient: AmbientStation, mac_address: str, station_name: str, - sensor_type: str, - sensor_name: str, - device_class: str | None, + description: EntityDescription, ) -> None: - """Initialize the sensor.""" + """Initialize the entity.""" self._ambient = ambient - 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._attr_name = f"{station_name}_{description.name}" + self._attr_unique_id = f"{mac_address}_{description.key}" self._mac_address = mac_address - self._sensor_type = sensor_type + self.entity_description = description async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -496,18 +242,18 @@ class AmbientWeatherEntity(Entity): @callback def update() -> None: """Update the state.""" - if self._sensor_type == TYPE_SOLARRADIATION_LX: + if self.entity_description.key == TYPE_SOLARRADIATION_LX: self._attr_available = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ TYPE_SOLARRADIATION - ) + ] is not None ) else: self._attr_available = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] is not None ) diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 093a582791e..e513486fb85 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -1,34 +1,209 @@ """Support for Ambient Weather Station binary sensors.""" from __future__ import annotations +from dataclasses import dataclass +from typing import Literal + from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CONNECTIVITY, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - SENSOR_TYPES, - TYPE_BATT1, - TYPE_BATT2, - TYPE_BATT3, - TYPE_BATT4, - TYPE_BATT5, - TYPE_BATT6, - TYPE_BATT7, - TYPE_BATT8, - TYPE_BATT9, - TYPE_BATT10, - TYPE_BATT_CO2, - TYPE_BATTOUT, - TYPE_PM25_BATT, - TYPE_PM25IN_BATT, - AmbientWeatherEntity, +from . import AmbientWeatherEntity +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN + +TYPE_BATT1 = "batt1" +TYPE_BATT10 = "batt10" +TYPE_BATT2 = "batt2" +TYPE_BATT3 = "batt3" +TYPE_BATT4 = "batt4" +TYPE_BATT5 = "batt5" +TYPE_BATT6 = "batt6" +TYPE_BATT7 = "batt7" +TYPE_BATT8 = "batt8" +TYPE_BATT9 = "batt9" +TYPE_BATT_CO2 = "batt_co2" +TYPE_BATTOUT = "battout" +TYPE_PM25_BATT = "batt_25" +TYPE_PM25IN_BATT = "batt_25in" +TYPE_RELAY1 = "relay1" +TYPE_RELAY10 = "relay10" +TYPE_RELAY2 = "relay2" +TYPE_RELAY3 = "relay3" +TYPE_RELAY4 = "relay4" +TYPE_RELAY5 = "relay5" +TYPE_RELAY6 = "relay6" +TYPE_RELAY7 = "relay7" +TYPE_RELAY8 = "relay8" +TYPE_RELAY9 = "relay9" + + +@dataclass +class AmbientBinarySensorDescriptionMixin: + """Define an entity description mixin for binary sensors.""" + + on_state: Literal[0, 1] + + +@dataclass +class AmbientBinarySensorDescription( + BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin +): + """Describe an Ambient PWS binary sensor.""" + + +BINARY_SENSOR_DESCRIPTIONS = ( + AmbientBinarySensorDescription( + key=TYPE_BATTOUT, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT1, + name="Battery 1", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT2, + name="Battery 2", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT3, + name="Battery 3", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT4, + name="Battery 4", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT5, + name="Battery 5", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT6, + name="Battery 6", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT7, + name="Battery 7", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT8, + name="Battery 8", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT9, + name="Battery 9", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT10, + name="Battery 10", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_BATT_CO2, + name="CO2 Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_PM25IN_BATT, + name="PM25 Indoor Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_PM25_BATT, + name="PM25 Battery", + device_class=DEVICE_CLASS_BATTERY, + on_state=0, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY1, + name="Relay 1", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY2, + name="Relay 2", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY3, + name="Relay 3", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY4, + name="Relay 4", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY5, + name="Relay 5", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY6, + name="Relay 6", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY7, + name="Relay 7", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY8, + name="Relay 8", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY9, + name="Relay 9", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), + AmbientBinarySensorDescription( + key=TYPE_RELAY10, + name="Relay 10", + device_class=DEVICE_CLASS_CONNECTIVITY, + on_state=1, + ), ) -from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN async def async_setup_entry( @@ -37,51 +212,29 @@ async def async_setup_entry( """Set up Ambient PWS binary sensors based on a config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - binary_sensor_list = [] - for mac_address, station in ambient.stations.items(): - for condition in station[ATTR_MONITORED_CONDITIONS]: - name, _, kind, device_class = SENSOR_TYPES[condition] - if kind == BINARY_SENSOR: - binary_sensor_list.append( - AmbientWeatherBinarySensor( - ambient, - mac_address, - station[ATTR_NAME], - condition, - name, - device_class, - ) - ) - - async_add_entities(binary_sensor_list) + async_add_entities( + [ + AmbientWeatherBinarySensor( + ambient, mac_address, station[ATTR_NAME], description + ) + for mac_address, station in ambient.stations.items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.key in station[ATTR_LAST_DATA] + ] + ) class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): """Define an Ambient binary sensor.""" + entity_description: AmbientBinarySensorDescription + @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 + self._attr_is_on = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] + == self.entity_description.on_state ) - - if self._sensor_type in ( - TYPE_BATT1, - TYPE_BATT10, - TYPE_BATT2, - TYPE_BATT3, - TYPE_BATT4, - TYPE_BATT5, - TYPE_BATT6, - TYPE_BATT7, - TYPE_BATT8, - TYPE_BATT9, - TYPE_BATT_CO2, - TYPE_BATTOUT, - TYPE_PM25_BATT, - TYPE_PM25IN_BATT, - ): - self._attr_is_on = state == 0 - else: - self._attr_is_on = state == 1 diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py index 87b5ff61877..cf5c97be045 100644 --- a/homeassistant/components/ambient_station/const.py +++ b/homeassistant/components/ambient_station/const.py @@ -5,8 +5,10 @@ DOMAIN = "ambient_station" LOGGER = logging.getLogger(__package__) ATTR_LAST_DATA = "last_data" -ATTR_MONITORED_CONDITIONS = "monitored_conditions" CONF_APP_KEY = "app_key" DATA_CLIENT = "data_client" + +TYPE_SOLARRADIATION = "solarradiation" +TYPE_SOLARRADIATION_LX = "solarradiation_lx" diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 935a53e9384..0247d03b6fd 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,20 +1,608 @@ """Support for Ambient Weather Station sensors.""" from __future__ import annotations -from homeassistant.components.sensor import DOMAIN as SENSOR, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_NAME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + 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 HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription 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 +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN + +TYPE_24HOURRAININ = "24hourrainin" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_HUMIDITY1 = "humidity1" +TYPE_HUMIDITY10 = "humidity10" +TYPE_HUMIDITY2 = "humidity2" +TYPE_HUMIDITY3 = "humidity3" +TYPE_HUMIDITY4 = "humidity4" +TYPE_HUMIDITY5 = "humidity5" +TYPE_HUMIDITY6 = "humidity6" +TYPE_HUMIDITY7 = "humidity7" +TYPE_HUMIDITY8 = "humidity8" +TYPE_HUMIDITY9 = "humidity9" +TYPE_HUMIDITYIN = "humidityin" +TYPE_LASTRAIN = "lastRain" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_PM25 = "pm25" +TYPE_PM25_24H = "pm25_24h" +TYPE_PM25_IN = "pm25_in" +TYPE_PM25_IN_24H = "pm25_in_24h" +TYPE_SOILHUM1 = "soilhum1" +TYPE_SOILHUM10 = "soilhum10" +TYPE_SOILHUM2 = "soilhum2" +TYPE_SOILHUM3 = "soilhum3" +TYPE_SOILHUM4 = "soilhum4" +TYPE_SOILHUM5 = "soilhum5" +TYPE_SOILHUM6 = "soilhum6" +TYPE_SOILHUM7 = "soilhum7" +TYPE_SOILHUM8 = "soilhum8" +TYPE_SOILHUM9 = "soilhum9" +TYPE_SOILTEMP1F = "soiltemp1f" +TYPE_SOILTEMP10F = "soiltemp10f" +TYPE_SOILTEMP2F = "soiltemp2f" +TYPE_SOILTEMP3F = "soiltemp3f" +TYPE_SOILTEMP4F = "soiltemp4f" +TYPE_SOILTEMP5F = "soiltemp5f" +TYPE_SOILTEMP6F = "soiltemp6f" +TYPE_SOILTEMP7F = "soiltemp7f" +TYPE_SOILTEMP8F = "soiltemp8f" +TYPE_SOILTEMP9F = "soiltemp9f" +TYPE_TEMP10F = "temp10f" +TYPE_TEMP1F = "temp1f" +TYPE_TEMP2F = "temp2f" +TYPE_TEMP3F = "temp3f" +TYPE_TEMP4F = "temp4f" +TYPE_TEMP5F = "temp5f" +TYPE_TEMP6F = "temp6f" +TYPE_TEMP7F = "temp7f" +TYPE_TEMP8F = "temp8f" +TYPE_TEMP9F = "temp9f" +TYPE_TEMPF = "tempf" +TYPE_TEMPINF = "tempinf" +TYPE_TOTALRAININ = "totalrainin" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDDIR_AVG10M = "winddir_avg10m" +TYPE_WINDDIR_AVG2M = "winddir_avg2m" +TYPE_WINDGUSTDIR = "windgustdir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" +TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_24HOURRAININ, + name="24 Hr Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key=TYPE_BAROMABSIN, + name="Abs Pressure", + native_unit_of_measurement=PRESSURE_INHG, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_BAROMRELIN, + name="Rel Pressure", + native_unit_of_measurement=PRESSURE_INHG, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_CO2, + name="co2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_DAILYRAININ, + name="Daily Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key=TYPE_DEWPOINT, + name="Dew Point", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_EVENTRAININ, + name="Event Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_FEELSLIKE, + name="Feels Like", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_HOURLYRAININ, + name="Hourly Rain Rate", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY10, + name="Humidity 10", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY1, + name="Humidity 1", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY2, + name="Humidity 2", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY3, + name="Humidity 3", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY4, + name="Humidity 4", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY5, + name="Humidity 5", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY6, + name="Humidity 6", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY7, + name="Humidity 7", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY8, + name="Humidity 8", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY9, + name="Humidity 9", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_HUMIDITYIN, + name="Humidity In", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_LASTRAIN, + name="Last Rain", + icon="mdi:water", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key=TYPE_MAXDAILYGUST, + name="Max Gust", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_MONTHLYRAININ, + name="Monthly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_PM25_24H, + name="PM25 24h Avg", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + ), + SensorEntityDescription( + key=TYPE_PM25_IN, + name="PM25 Indoor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_PM25_IN_24H, + name="PM25 Indoor 24h Avg", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + ), + SensorEntityDescription( + key=TYPE_PM25, + name="PM25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILHUM10, + name="Soil Humidity 10", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM1, + name="Soil Humidity 1", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM2, + name="Soil Humidity 2", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM3, + name="Soil Humidity 3", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM4, + name="Soil Humidity 4", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM5, + name="Soil Humidity 5", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM6, + name="Soil Humidity 6", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM7, + name="Soil Humidity 7", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM8, + name="Soil Humidity 8", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILHUM9, + name="Soil Humidity 9", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP10F, + name="Soil Temp 10", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP1F, + name="Soil Temp 1", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP2F, + name="Soil Temp 2", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP3F, + name="Soil Temp 3", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP4F, + name="Soil Temp 4", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP5F, + name="Soil Temp 5", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP6F, + name="Soil Temp 6", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP7F, + name="Soil Temp 7", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP8F, + name="Soil Temp 8", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOILTEMP9F, + name="Soil Temp 9", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION, + name="Solar Rad", + native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION_LX, + name="Solar Rad", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP10F, + name="Temp 10", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP1F, + name="Temp 1", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP2F, + name="Temp 2", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP3F, + name="Temp 3", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP4F, + name="Temp 4", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP5F, + name="Temp 5", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP6F, + name="Temp 6", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP7F, + name="Temp 7", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP8F, + name="Temp 8", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMP9F, + name="Temp 9", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMPF, + name="Temp", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TEMPINF, + name="Inside Temp", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_TOTALRAININ, + name="Lifetime Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_UV, + name="UV Index", + native_unit_of_measurement="Index", + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_WEEKLYRAININ, + name="Weekly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_WINDDIR, + name="Wind Dir", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDDIR_AVG10M, + name="Wind Dir Avg 10m", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDDIR_AVG2M, + name="Wind Dir Avg 2m", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTDIR, + name="Gust Dir", + icon="mdi:weather-windy", + native_unit_of_measurement=DEGREE, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTMPH, + name="Wind Gust", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_WINDSPDMPH_AVG10M, + name="Wind Avg 10m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPDMPH_AVG2M, + name="Wind Avg 2m", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + ), + SensorEntityDescription( + key=TYPE_WINDSPEEDMPH, + name="Wind Speed", + icon="mdi:weather-windy", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_YEARLYRAININ, + name="Yearly Rain", + icon="mdi:water", + native_unit_of_measurement=PRECIPITATION_INCHES, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) async def async_setup_entry( @@ -23,24 +611,14 @@ async def async_setup_entry( """Set up Ambient PWS sensors based on a config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - sensor_list = [] - for mac_address, station in ambient.stations.items(): - for condition in station[ATTR_MONITORED_CONDITIONS]: - name, unit, kind, device_class = SENSOR_TYPES[condition] - if kind == SENSOR: - sensor_list.append( - AmbientWeatherSensor( - ambient, - mac_address, - station[ATTR_NAME], - condition, - name, - device_class, - unit, - ) - ) - - async_add_entities(sensor_list) + async_add_entities( + [ + AmbientWeatherSensor(ambient, mac_address, station[ATTR_NAME], description) + for mac_address, station in ambient.stations.items() + for description in SENSOR_DESCRIPTIONS + if description.key in station[ATTR_LAST_DATA] + ] + ) class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): @@ -51,34 +629,20 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): ambient: AmbientStation, mac_address: str, station_name: str, - sensor_type: str, - sensor_name: str, - device_class: str | None, - unit: str | None, + description: EntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__( - ambient, mac_address, station_name, sensor_type, sensor_name, device_class - ) + super().__init__(ambient, mac_address, station_name, description) - self._attr_native_unit_of_measurement = unit + if description.key == TYPE_SOLARRADIATION_LX: + # Since TYPE_SOLARRADIATION and TYPE_SOLARRADIATION_LX will have the same + # name in the UI, we influence the entity ID of TYPE_SOLARRADIATION_LX here + # to differentiate them: + self.entity_id = f"sensor.{station_name}_solar_rad_lx" @callback 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 - # value of the solarradiation sensor and apply a very accurate - # approximation of converting sunlight W/m^2 to lx: - w_m2_brightness_val = self._ambient.stations[self._mac_address][ - ATTR_LAST_DATA - ].get(TYPE_SOLARRADIATION) - - if w_m2_brightness_val is None: - self._attr_native_value = None - else: - self._attr_native_value = round(float(w_m2_brightness_val) / 0.0079) - else: - self._attr_native_value = self._ambient.stations[self._mac_address][ - ATTR_LAST_DATA - ].get(self._sensor_type) + self._attr_native_value = self._ambient.stations[self._mac_address][ + ATTR_LAST_DATA + ][self.entity_description.key] diff --git a/homeassistant/components/ambient_station/translations/fr.json b/homeassistant/components/ambient_station/translations/fr.json index d88e9f9c9f6..1877a0af4ff 100644 --- a/homeassistant/components/ambient_station/translations/fr.json +++ b/homeassistant/components/ambient_station/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Cette cl\u00e9 d'application est d\u00e9j\u00e0 utilis\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_key": "Cl\u00e9 d'API et / ou cl\u00e9 d'application non valide", + "invalid_key": "Cl\u00e9 API invalide", "no_devices": "Aucun appareil trouv\u00e9 dans le compte" }, "step": { diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 26247816ac9..18aa2006f72 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,12 +1,13 @@ """Support for Amcrest IP cameras.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta import logging import threading -from typing import Any, Callable +from typing import Any import aiohttp from amcrest import AmcrestError, ApiWrapper, LoginError @@ -379,3 +380,4 @@ class AmcrestDevice: stream_source: str resolution: int control_light: bool + channel: int = 0 diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 93e5b17d548..8d2535a142b 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,11 +1,12 @@ """Support for Amcrest IP camera binary sensors.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from amcrest import AmcrestError import voluptuous as vol @@ -111,6 +112,7 @@ BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = ( key=_ONLINE_KEY, name="Online", device_class=DEVICE_CLASS_CONNECTIVITY, + should_poll=True, ), ) BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS] @@ -168,6 +170,7 @@ class AmcrestBinarySensor(BinarySensorEntity): """Initialize entity.""" self._signal_name = name self._api = device.api + self._channel = device.channel self.entity_description: AmcrestSensorEntityDescription = entity_description self._attr_name = f"{name} {entity_description.name}" @@ -191,12 +194,14 @@ class AmcrestBinarySensor(BinarySensorEntity): if not (self._api.available or self.is_on): return _LOGGER.debug(_UPDATE_MSG, self.name) + if self._api.available: # Send a command to the camera to test if we can still communicate with it. # Override of Http.command() in __init__.py will set self._api.available # accordingly. with suppress(AmcrestError): self._api.current_time # pylint: disable=pointless-statement + self._update_unique_id() self._attr_is_on = self._api.available def _update_others(self) -> None: @@ -204,6 +209,12 @@ class AmcrestBinarySensor(BinarySensorEntity): return _LOGGER.debug(_UPDATE_MSG, self.name) + try: + self._update_unique_id() + except AmcrestError as error: + log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + return + event_code = self.entity_description.event_code if event_code is None: _LOGGER.error("Binary sensor %s event code not set", self.name) @@ -213,6 +224,16 @@ class AmcrestBinarySensor(BinarySensorEntity): self._attr_is_on = len(self._api.event_channels_happened(event_code)) > 0 except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + return + + def _update_unique_id(self) -> None: + """Set the unique id.""" + if self._attr_unique_id is None: + serial_number = self._api.serial_number + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{self.entity_description.key}-{self._channel}" + ) async def async_on_demand_update(self) -> None: """Update state.""" @@ -232,8 +253,6 @@ class AmcrestBinarySensor(BinarySensorEntity): async def async_added_to_hass(self) -> None: """Subscribe to signals.""" - assert self.hass is not None - self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 772824864df..8333ece1030 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta from functools import partial import logging -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from aiohttp import web from amcrest import AmcrestError @@ -13,9 +14,11 @@ from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import SUPPORT_ON_OFF, SUPPORT_STREAM, Camera +from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN from homeassistant.components.ffmpeg import DATA_FFMPEG, FFmpegManager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -32,6 +35,7 @@ from .const import ( COMM_TIMEOUT, DATA_AMCREST, DEVICES, + DOMAIN, SERVICE_UPDATE, SNAPSHOT_TIMEOUT, ) @@ -132,7 +136,21 @@ async def async_setup_platform( name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] - async_add_entities([AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) + entity = AmcrestCam(name, device, hass.data[DATA_FFMPEG]) + + # 2021.9.0 introduced unique id's for the camera entity, but these were not + # unique for different resolution streams. If any cameras were configured + # with this version, update the old entity with the new unique id. + serial_number = await hass.async_add_executor_job(lambda: device.api.serial_number) # type: ignore[no-any-return] + serial_number = serial_number.strip() + registry = entity_registry.async_get(hass) + entity_id = registry.async_get_entity_id(CAMERA_DOMAIN, DOMAIN, serial_number) + if entity_id is not None: + _LOGGER.debug("Updating unique id for camera %s", entity_id) + new_unique_id = f"{serial_number}-{device.resolution}-{device.channel}" + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + async_add_entities([entity], True) class CannotSnapshot(Exception): @@ -155,6 +173,7 @@ class AmcrestCam(Camera): self._ffmpeg_arguments = device.ffmpeg_arguments self._stream_source = device.stream_source self._resolution = device.resolution + self._channel = device.channel self._token = self._auth = device.authentication self._control_light = device.control_light self._is_recording: bool = False @@ -180,7 +199,6 @@ class AmcrestCam(Camera): raise CannotSnapshot async def _async_get_image(self) -> None: - assert self.hass is not None try: # Send the request to snap a picture and return raw jpg data # Snapshot command needs a much longer read timeout than other commands. @@ -201,7 +219,6 @@ class AmcrestCam(Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - assert self.hass is not None _LOGGER.debug("Take snapshot from %s", self._name) try: # Amcrest cameras only support one snapshot command at a time. @@ -226,7 +243,6 @@ class AmcrestCam(Camera): self, request: web.Request ) -> web.StreamResponse | None: """Return an MJPEG stream.""" - assert self.hass is not None # The snapshot implementation is handled by the parent class if self._stream_source == "snapshot": return await super().handle_async_mjpeg_stream(request) @@ -344,7 +360,6 @@ class AmcrestCam(Camera): async def async_added_to_hass(self) -> None: """Subscribe to signals and add camera to list.""" - assert self.hass is not None self._unsub_dispatcher.extend( async_dispatcher_connect( self.hass, @@ -364,7 +379,6 @@ class AmcrestCam(Camera): async def async_will_remove_from_hass(self) -> None: """Remove camera from list and disconnect from signals.""" - assert self.hass is not None self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() @@ -379,20 +393,25 @@ class AmcrestCam(Camera): try: if self._brand is None: resp = self._api.vendor_information.strip() - if resp.startswith("vendor="): - self._brand = resp.split("=")[-1] + _LOGGER.debug("Assigned brand=%s", resp) + if resp: + self._brand = resp else: self._brand = "unknown" if self._model is None: resp = self._api.device_type.strip() - _LOGGER.debug("Device_type=%s", resp) - if resp.startswith("type="): - self._model = resp.split("=")[-1] + _LOGGER.debug("Assigned model=%s", resp) + if resp: + self._model = resp else: self._model = "unknown" if self._attr_unique_id is None: - self._attr_unique_id = self._api.serial_number.strip() - _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) + serial_number = self._api.serial_number.strip() + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{self._resolution}-{self._channel}" + ) + _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) self.is_streaming = self._get_video() self._is_recording = self._get_recording() self._motion_detection_enabled = self._get_motion_detection() @@ -428,57 +447,46 @@ class AmcrestCam(Camera): async def async_enable_recording(self) -> None: """Call the job and enable recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_recording, True) async def async_disable_recording(self) -> None: """Call the job and disable recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_recording, False) async def async_enable_audio(self) -> None: """Call the job and enable audio.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_audio, True) async def async_disable_audio(self) -> None: """Call the job and disable audio.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_audio, False) async def async_enable_motion_recording(self) -> None: """Call the job and enable motion recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_motion_recording, True) async def async_disable_motion_recording(self) -> None: """Call the job and disable motion recording.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._enable_motion_recording, False) async def async_goto_preset(self, preset: int) -> None: """Call the job and move camera to preset position.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._goto_preset, preset) async def async_set_color_bw(self, color_bw: str) -> None: """Call the job and set camera color mode.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._set_color_bw, color_bw) async def async_start_tour(self) -> None: """Call the job and start camera tour.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._start_tour, True) async def async_stop_tour(self) -> None: """Call the job and stop camera tour.""" - assert self.hass is not None await self.hass.async_add_executor_job(self._start_tour, False) async def async_ptz_control(self, movement: str, travel_time: float) -> None: """Move or zoom camera in specified direction.""" - assert self.hass is not None code = _ACTION[_MOV.index(movement)] kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0} diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 725ff96b3ad..0d6c1380c20 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,7 +2,7 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.8.1"], + "requirements": ["amcrest==1.9.3"], "dependencies": ["ffmpeg"], "codeowners": ["@flacjacket"], "iot_class": "local_polling" diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index b916757f44a..f2048654da6 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,9 +1,10 @@ """Support for Amcrest IP camera sensors.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from amcrest import AmcrestError @@ -77,6 +78,7 @@ class AmcrestSensor(SensorEntity): self.entity_description = description self._signal_name = name self._api = device.api + self._channel = device.channel self._unsub_dispatcher: Callable[[], None] | None = None self._attr_name = f"{name} {description.name}" @@ -94,7 +96,19 @@ class AmcrestSensor(SensorEntity): _LOGGER.debug("Updating %s sensor", self.name) sensor_type = self.entity_description.key + if self._attr_unique_id is None: + serial_number = self._api.serial_number + if serial_number: + self._attr_unique_id = f"{serial_number}-{sensor_type}-{self._channel}" + try: + if self._attr_unique_id is None: + serial_number = self._api.serial_number + if serial_number: + self._attr_unique_id = ( + f"{serial_number}-{sensor_type}-{self._channel}" + ) + if sensor_type == SENSOR_PTZ_PRESET: self._attr_native_value = self._api.ptz_presets_count @@ -129,7 +143,6 @@ class AmcrestSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Subscribe to update signal.""" - assert self.hass is not None self._unsub_dispatcher = async_dispatcher_connect( self.hass, service_signal(SERVICE_UPDATE, self._signal_name), diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 8bc53bd86b7..533470181c1 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -358,6 +358,7 @@ def adb_decorator(override_available=False): @functools.wraps(func) async def _adb_exception_catcher(self, *args, **kwargs): """Call an ADB-related method and catch exceptions.""" + # pylint: disable=protected-access if not self.available and not override_available: return None diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 5937ff6a852..f7a350925ec 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -1,10 +1,16 @@ """Support for APCUPSd sensors.""" +from __future__ import annotations + import logging from apcaccess.status import ALL_UNITS 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_RESOURCES, DEVICE_CLASS_TEMPERATURE, @@ -25,74 +31,360 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = "UPS " -SENSOR_TYPES = { - "alarmdel": ["Alarm Delay", None, "mdi:alarm", None], - "ambtemp": ["Ambient Temperature", None, "mdi:thermometer", None], - "apc": ["Status Data", None, "mdi:information-outline", None], - "apcmodel": ["Model", None, "mdi:information-outline", None], - "badbatts": ["Bad Batteries", None, "mdi:information-outline", None], - "battdate": ["Battery Replaced", None, "mdi:calendar-clock", None], - "battstat": ["Battery Status", None, "mdi:information-outline", None], - "battv": ["Battery Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "bcharge": ["Battery", PERCENTAGE, "mdi:battery", None], - "cable": ["Cable Type", None, "mdi:ethernet-cable", None], - "cumonbatt": ["Total Time on Battery", None, "mdi:timer-outline", None], - "date": ["Status Date", None, "mdi:calendar-clock", None], - "dipsw": ["Dip Switch Settings", None, "mdi:information-outline", None], - "dlowbatt": ["Low Battery Signal", None, "mdi:clock-alert", None], - "driver": ["Driver", None, "mdi:information-outline", None], - "dshutd": ["Shutdown Delay", None, "mdi:timer-outline", None], - "dwake": ["Wake Delay", None, "mdi:timer-outline", None], - "endapc": ["Date and Time", None, "mdi:calendar-clock", None], - "extbatts": ["External Batteries", None, "mdi:information-outline", None], - "firmware": ["Firmware Version", None, "mdi:information-outline", None], - "hitrans": ["Transfer High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "hostname": ["Hostname", None, "mdi:information-outline", None], - "humidity": ["Ambient Humidity", PERCENTAGE, "mdi:water-percent", None], - "itemp": ["Internal Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "lastxfer": ["Last Transfer", None, "mdi:transfer", None], - "linefail": ["Input Voltage Status", None, "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", None, "mdi:calendar", None], - "masterupd": ["Master Update", None, "mdi:information-outline", None], - "maxlinev": ["Input Voltage High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "maxtime": ["Battery Timeout", None, "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", None, "mdi:timer-outline", None], - "model": ["Model", None, "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", None, "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", None, "mdi:information-outline", None], - "reg2": ["Register 2 Fault", None, "mdi:information-outline", None], - "reg3": ["Register 3 Fault", None, "mdi:information-outline", None], - "retpct": ["Restore Requirement", PERCENTAGE, "mdi:battery-alert", None], - "selftest": ["Last Self Test", None, "mdi:calendar-clock", None], - "sense": ["Sensitivity", None, "mdi:information-outline", None], - "serialno": ["Serial Number", None, "mdi:information-outline", None], - "starttime": ["Startup Time", None, "mdi:calendar-clock", None], - "statflag": ["Status Flag", None, "mdi:information-outline", None], - "status": ["Status", None, "mdi:information-outline", None], - "stesti": ["Self Test Interval", None, "mdi:information-outline", None], - "timeleft": ["Time Left", None, "mdi:clock-alert", None], - "tonbatt": ["Time on Battery", None, "mdi:timer-outline", None], - "upsmode": ["Mode", None, "mdi:information-outline", None], - "upsname": ["Name", None, "mdi:information-outline", None], - "version": ["Daemon Info", None, "mdi:information-outline", None], - "xoffbat": ["Transfer from Battery", None, "mdi:transfer", None], - "xoffbatt": ["Transfer from Battery", None, "mdi:transfer", None], - "xonbatt": ["Transfer to Battery", None, "mdi:transfer", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="alarmdel", + name="Alarm Delay", + icon="mdi:alarm", + ), + SensorEntityDescription( + key="ambtemp", + name="Ambient Temperature", + icon="mdi:thermometer", + ), + SensorEntityDescription( + key="apc", + name="Status Data", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="apcmodel", + name="Model", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="badbatts", + name="Bad Batteries", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="battdate", + name="Battery Replaced", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="battstat", + name="Battery Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="battv", + name="Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="bcharge", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + SensorEntityDescription( + key="cable", + name="Cable Type", + icon="mdi:ethernet-cable", + ), + SensorEntityDescription( + key="cumonbatt", + name="Total Time on Battery", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="date", + name="Status Date", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="dipsw", + name="Dip Switch Settings", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="dlowbatt", + name="Low Battery Signal", + icon="mdi:clock-alert", + ), + SensorEntityDescription( + key="driver", + name="Driver", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="dshutd", + name="Shutdown Delay", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="dwake", + name="Wake Delay", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="endapc", + name="Date and Time", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="extbatts", + name="External Batteries", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="firmware", + name="Firmware Version", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="hitrans", + name="Transfer High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="hostname", + name="Hostname", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="humidity", + name="Ambient Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="itemp", + name="Internal Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="lastxfer", + name="Last Transfer", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="linefail", + name="Input Voltage Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="linefreq", + name="Line Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="linev", + name="Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="loadpct", + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="loadapnt", + name="Load Apparent Power", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="lotrans", + name="Transfer Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="mandate", + name="Manufacture Date", + icon="mdi:calendar", + ), + SensorEntityDescription( + key="masterupd", + name="Master Update", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="maxlinev", + name="Input Voltage High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="maxtime", + name="Battery Timeout", + icon="mdi:timer-off-outline", + ), + SensorEntityDescription( + key="mbattchg", + name="Battery Shutdown", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-alert", + ), + SensorEntityDescription( + key="minlinev", + name="Input Voltage Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="mintimel", + name="Shutdown Time", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="model", + name="Model", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="nombattv", + name="Battery Nominal Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nominv", + name="Nominal Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nomoutv", + name="Nominal Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nompower", + name="Nominal Output Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="nomapnt", + name="Nominal Apparent Power", + native_unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + ), + SensorEntityDescription( + key="numxfers", + name="Transfer Count", + icon="mdi:counter", + ), + SensorEntityDescription( + key="outcurnt", + name="Output Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + ), + SensorEntityDescription( + key="outputv", + name="Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="reg1", + name="Register 1 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="reg2", + name="Register 2 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="reg3", + name="Register 3 Fault", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="retpct", + name="Restore Requirement", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-alert", + ), + SensorEntityDescription( + key="selftest", + name="Last Self Test", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="sense", + name="Sensitivity", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="serialno", + name="Serial Number", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="starttime", + name="Startup Time", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="statflag", + name="Status Flag", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="status", + name="Status", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="stesti", + name="Self Test Interval", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="timeleft", + name="Time Left", + icon="mdi:clock-alert", + ), + SensorEntityDescription( + key="tonbatt", + name="Time on Battery", + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="upsmode", + name="Mode", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="upsname", + name="Name", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="version", + name="Daemon Info", + icon="mdi:information-outline", + ), + SensorEntityDescription( + key="xoffbat", + name="Transfer from Battery", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="xoffbatt", + name="Transfer from Battery", + icon="mdi:transfer", + ), + SensorEntityDescription( + key="xonbatt", + name="Transfer to Battery", + icon="mdi:transfer", + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} INFERRED_UNITS = { @@ -111,7 +403,7 @@ INFERRED_UNITS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -120,25 +412,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the APCUPSd sensors.""" apcups_data = hass.data[DOMAIN] - entities = [] + resources = config[CONF_RESOURCES] - for resource in config[CONF_RESOURCES]: - sensor_type = resource.lower() - - if sensor_type not in SENSOR_TYPES: - SENSOR_TYPES[sensor_type] = [ - sensor_type.title(), - "", - "mdi:information-outline", - ] - - if sensor_type.upper() not in apcups_data.status: + for resource in resources: + if resource.upper() not in apcups_data.status: _LOGGER.warning( "Sensor type: %s does not appear in the APCUPSd status output", - sensor_type, + resource, ) - entities.append(APCUPSdSensor(apcups_data, sensor_type)) + entities = [ + APCUPSdSensor(apcups_data, description) + for description in SENSOR_TYPES + if description.key in resources + ] add_entities(entities, True) @@ -159,22 +446,18 @@ def infer_unit(value): class APCUPSdSensor(SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" - def __init__(self, data, sensor_type): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._data = data - self.type = sensor_type - self._attr_name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] - self._attr_icon = SENSOR_TYPES[self.type][2] - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][3] + self._attr_name = f"{SENSOR_PREFIX}{description.name}" def update(self): """Get the latest status and use it to update our sensor state.""" - if self.type.upper() not in self._data.status: + key = self.entity_description.key.upper() + if key not in self._data.status: self._attr_native_value = None else: - self._attr_native_value, inferred_unit = infer_unit( - self._data.status[self.type.upper()] - ) - if not self._attr_native_unit_of_measurement: + self._attr_native_value, inferred_unit = infer_unit(self._data.status[key]) + if not self.native_unit_of_measurement: self._attr_native_unit_of_measurement = inferred_unit diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a91d8540286..01d48a190fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,6 +1,6 @@ """Rest API for Home Assistant.""" import asyncio -from contextlib import suppress +from http import HTTPStatus import json import logging @@ -15,10 +15,6 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, - HTTP_BAD_REQUEST, - HTTP_CREATED, - HTTP_NOT_FOUND, - HTTP_OK, MATCH_ALL, URL_API, URL_API_COMPONENTS, @@ -30,15 +26,12 @@ from homeassistant.const import ( URL_API_STATES, URL_API_STREAM, URL_API_TEMPLATE, - __version__, ) import homeassistant.core as ha from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_get_all_descriptions -from homeassistant.helpers.system_info import async_get_system_info _LOGGER = logging.getLogger(__name__) @@ -97,6 +90,7 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() hass = request.app["hass"] @@ -173,7 +167,11 @@ class APIConfigView(HomeAssistantView): class APIDiscoveryView(HomeAssistantView): - """View to provide Discovery information.""" + """ + View to provide Discovery information. + + DEPRECATED: To be removed in 2022.1 + """ requires_auth = False url = URL_API_DISCOVERY_INFO @@ -181,32 +179,18 @@ class APIDiscoveryView(HomeAssistantView): async def get(self, request): """Get discovery information.""" - hass = request.app["hass"] - uuid = await hass.helpers.instance_id.async_get() - system_info = await async_get_system_info(hass) - - data = { - ATTR_UUID: uuid, - ATTR_BASE_URL: None, - ATTR_EXTERNAL_URL: None, - ATTR_INTERNAL_URL: None, - ATTR_LOCATION_NAME: hass.config.location_name, - ATTR_INSTALLATION_TYPE: system_info[ATTR_INSTALLATION_TYPE], - # always needs authentication - ATTR_REQUIRES_API_PASSWORD: True, - ATTR_VERSION: __version__, - } - - with suppress(NoURLAvailableError): - data["external_url"] = get_url(hass, allow_internal=False) - - with suppress(NoURLAvailableError): - data["internal_url"] = get_url(hass, allow_external=False) - - # Set old base URL based on external or internal - data["base_url"] = data["external_url"] or data["internal_url"] - - return self.json(data) + return self.json( + { + ATTR_UUID: "", + ATTR_BASE_URL: "", + ATTR_EXTERNAL_URL: "", + ATTR_INTERNAL_URL: "", + ATTR_LOCATION_NAME: "", + ATTR_INSTALLATION_TYPE: "", + ATTR_REQUIRES_API_PASSWORD: True, + ATTR_VERSION: "", + } + ) class APIStatesView(HomeAssistantView): @@ -244,7 +228,7 @@ class APIEntityStateView(HomeAssistantView): state = request.app["hass"].states.get(entity_id) if state: return self.json(state) - return self.json_message("Entity not found.", HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) async def post(self, request, entity_id): """Update state of entity.""" @@ -254,12 +238,12 @@ class APIEntityStateView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified.", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) new_state = data.get("state") if new_state is None: - return self.json_message("No state specified.", HTTP_BAD_REQUEST) + return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST) attributes = data.get("attributes") force_update = data.get("force_update", False) @@ -272,7 +256,7 @@ class APIEntityStateView(HomeAssistantView): ) # Read the state back for our response - status_code = HTTP_CREATED if is_new_state else HTTP_OK + status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK resp = self.json(hass.states.get(entity_id), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") @@ -286,7 +270,7 @@ class APIEntityStateView(HomeAssistantView): raise Unauthorized(entity_id=entity_id) if request.app["hass"].states.async_remove(entity_id): return self.json_message("Entity removed.") - return self.json_message("Entity not found.", HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) class APIEventListenersView(HomeAssistantView): @@ -316,12 +300,12 @@ class APIEventView(HomeAssistantView): event_data = json.loads(body) if body else None except ValueError: return self.json_message( - "Event data should be valid JSON.", HTTP_BAD_REQUEST + "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST ) if event_data is not None and not isinstance(event_data, dict): return self.json_message( - "Event data should be a JSON object", HTTP_BAD_REQUEST + "Event data should be a JSON object", HTTPStatus.BAD_REQUEST ) # Special case handling for event STATE_CHANGED @@ -368,7 +352,9 @@ class APIDomainServicesView(HomeAssistantView): try: data = json.loads(body) if body else None except ValueError: - return self.json_message("Data should be valid JSON.", HTTP_BAD_REQUEST) + return self.json_message( + "Data should be valid JSON.", HTTPStatus.BAD_REQUEST + ) context = self.context(request) @@ -416,7 +402,7 @@ class APITemplateView(HomeAssistantView): return tpl.async_render(variables=data.get("variables"), parse_result=False) except (ValueError, TemplateError) as ex: return self.json_message( - f"Error rendering template: {ex}", HTTP_BAD_REQUEST + f"Error rendering template: {ex}", HTTPStatus.BAD_REQUEST ) @@ -428,6 +414,7 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index a726e616641..1f3662b11d4 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -5,7 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "requirements": ["pyatv==0.8.2"], "zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."], - "after_dependencies": ["discovery"], "codeowners": ["@postlund"], "iot_class": "local_push" } diff --git a/homeassistant/components/apple_tv/translations/fr.json b/homeassistant/components/apple_tv/translations/fr.json index e1a719b31c9..056a98ea74f 100644 --- a/homeassistant/components/apple_tv/translations/fr.json +++ b/homeassistant/components/apple_tv/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured_device": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9", + "already_configured_device": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "backoff": "L'appareil n'accepte pas les demandes d'appariement pour le moment (vous avez peut-\u00eatre saisi un code PIN non valide trop de fois), r\u00e9essayez plus tard.", "device_did_not_pair": "Aucune tentative pour terminer l'appairage n'a \u00e9t\u00e9 effectu\u00e9e \u00e0 partir de l'appareil.", @@ -11,10 +11,10 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "invalid_auth": "Autentification invalide", - "no_devices_found": "Aucun appareil d\u00e9tect\u00e9 sur le r\u00e9seau", + "invalid_auth": "Authentification invalide", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "no_usable_service": "Un dispositif a \u00e9t\u00e9 trouv\u00e9, mais aucun moyen d\u2019\u00e9tablir un lien avec lui. Si vous continuez \u00e0 voir ce message, essayez de sp\u00e9cifier son adresse IP ou de red\u00e9marrer votre Apple TV.", - "unknown": "Erreur innatendue" + "unknown": "Erreur inattendue" }, "flow_title": "Apple TV: {name}", "step": { diff --git a/homeassistant/components/apple_tv/translations/hu.json b/homeassistant/components/apple_tv/translations/hu.json index 2b6275fc9f5..3d254422baf 100644 --- a/homeassistant/components/apple_tv/translations/hu.json +++ b/homeassistant/components/apple_tv/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s 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.", @@ -19,7 +19,7 @@ "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!", + "description": "Arra k\u00e9sz\u00fcl, hogy felvegye {name} nev\u0171 Apple TV-t a Home Assistant p\u00e9ld\u00e1ny\u00e1ba. \n\n ** A folyamat befejez\u00e9s\u00e9hez t\u00f6bb PIN-k\u00f3dot kell megadnia. ** \n\nFelh\u00edvjuk figyelm\u00e9t, hogy ezzel az integr\u00e1ci\u00f3val *nem* fogja tudni kikapcsolni az Apple TV-t. Csak a Home Assistant saj\u00e1t m\u00e9dialej\u00e1tsz\u00f3ja kapcsol ki!", "title": "Apple TV sikeresen hozz\u00e1adva" }, "pair_no_pin": { @@ -30,7 +30,7 @@ "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.", + "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, pl. \u00edrja be a 123 \u00e9rt\u00e9ket, ha a megjelen\u00edtett k\u00f3d 0123.", "title": "P\u00e1ros\u00edt\u00e1s" }, "reconfigure": { @@ -45,7 +45,7 @@ "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}", + "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\nHa 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" } } diff --git a/homeassistant/components/apple_tv/translations/id.json b/homeassistant/components/apple_tv/translations/id.json index 5646b498242..209ecbf8a83 100644 --- a/homeassistant/components/apple_tv/translations/id.json +++ b/homeassistant/components/apple_tv/translations/id.json @@ -16,7 +16,7 @@ "no_usable_service": "Perangkat ditemukan tetapi kami tidak dapat mengidentifikasi berbagai cara untuk membuat koneksi ke perangkat tersebut. Jika Anda terus melihat pesan ini, coba tentukan alamat IP-nya atau mulai ulang Apple TV Anda.", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Anda akan menambahkan Apple TV bernama `{name}` ke Home Assistant.\n\n** Untuk menyelesaikan proses, Anda mungkin harus memasukkan kode PIN beberapa kali.**\n\nPerhatikan bahwa Anda *tidak* akan dapat mematikan Apple TV dengan integrasi ini. Hanya pemutar media di Home Assistant yang akan dimatikan!", diff --git a/homeassistant/components/apple_tv/translations/zh-Hans.json b/homeassistant/components/apple_tv/translations/zh-Hans.json index 54095a0a633..4b178c75fce 100644 --- a/homeassistant/components/apple_tv/translations/zh-Hans.json +++ b/homeassistant/components/apple_tv/translations/zh-Hans.json @@ -1,18 +1,46 @@ { "config": { + "abort": { + "already_configured_device": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "backoff": "\u8bbe\u5907\u76ee\u524d\u6682\u4e0d\u63a5\u53d7\u914d\u5bf9\u8bf7\u6c42\uff08\u53ef\u80fd\u591a\u6b21\u8f93\u5165\u65e0\u6548 PIN \u7801\uff09\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002", + "invalid_config": "\u6b64\u8bbe\u5907\u7684\u914d\u7f6e\u4fe1\u606f\u4e0d\u5b8c\u6574\u3002\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u6dfb\u52a0\u3002", + "no_devices_found": "\u672a\u5728\u6b64\u7f51\u7edc\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "invalid_auth": "\u51ed\u636e\u65e0\u6548", + "no_devices_found": "\u672a\u5728\u6b64\u7f51\u7edc\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "no_usable_service": "\u5df2\u76f8\u5173\u627e\u5230\u8bbe\u5907\uff0c\u4f46\u65e0\u6cd5\u8bc6\u522b\u5e76\u4e0e\u5176\u5efa\u7acb\u8fde\u63a5\u3002\u82e5\u60a8\u4e00\u76f4\u6536\u5230\u6b64\u8b66\u544a\u6d88\u606f\uff0c\u8bf7\u5c1d\u8bd5\u4e3a\u5176\u6307\u5b9a\u56fa\u5b9a IP \u5730\u5740\u6216\u91cd\u65b0\u542f\u52a8\u60a8\u7684 Apple TV\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, "step": { "confirm": { - "description": "\u60a8\u5373\u5c06\u6dfb\u52a0 Apple TV (\u540d\u79f0\u4e3a\u201c{name}\u201d)\u5230 Home Assistant\u3002 \n\n **\u8981\u5b8c\u6210\u6b64\u8fc7\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u8f93\u5165\u591a\u4e2a PIN \u7801\u3002** \n\n\u8bf7\u6ce8\u610f\uff0c\u6b64\u96c6\u6210*\u4e0d\u80fd*\u5173\u95ed Apple TV \u7684\u7535\u6e90\uff0c\u53ea\u4f1a\u5173\u95ed Home Assistant \u4e2d\u7684\u5a92\u4f53\u64ad\u653e\u5668\uff01" + "description": "\u60a8\u5373\u5c06\u6dfb\u52a0 Apple TV (\u540d\u79f0\u4e3a\u201c{name}\u201d)\u5230 Home Assistant\u3002 \n\n **\u8981\u5b8c\u6210\u6b64\u8fc7\u7a0b\uff0c\u53ef\u80fd\u9700\u8981\u8f93\u5165\u591a\u4e2a PIN \u7801\u3002** \n\n\u8bf7\u6ce8\u610f\uff0c\u6b64\u96c6\u6210*\u4e0d\u80fd*\u5173\u95ed Apple TV \u7684\u7535\u6e90\uff0c\u53ea\u4f1a\u5173\u95ed Home Assistant \u4e2d\u7684\u5a92\u4f53\u64ad\u653e\u5668\uff01", + "title": "\u786e\u8ba4\u6dfb\u52a0 Apple TV" }, "pair_no_pin": { + "description": "`{protocol}` \u670d\u52a1\u9700\u8981\u914d\u5bf9\u3002\u8bf7\u5728\u60a8\u7684 Apple TV \u4e0a\u8f93\u5165 PIN {pin}", "title": "\u914d\u5bf9\u4e2d" }, "pair_with_pin": { "data": { "pin": "PIN\u7801" - } + }, + "title": "\u914d\u5bf9\u4e2d" + }, + "reconfigure": { + "description": "\u8be5 Apple TV \u9047\u5230\u4e00\u4e9b\u8fde\u63a5\u95ee\u9898\uff0c\u987b\u91cd\u65b0\u914d\u7f6e\u3002", + "title": "\u8bbe\u5907\u91cd\u65b0\u914d\u7f6e" + }, + "service_problem": { + "title": "\u6dfb\u52a0\u670d\u52a1\u5931\u8d25" }, "user": { + "data": { + "device_input": "\u8bbe\u5907\u5730\u5740" + }, "description": "\u8981\u5f00\u59cb\uff0c\u8bf7\u8f93\u5165\u8981\u6dfb\u52a0\u7684 Apple TV \u7684\u8bbe\u5907\u540d\u79f0\u6216 IP \u5730\u5740\u3002\u5728\u7f51\u7edc\u4e0a\u81ea\u52a8\u53d1\u73b0\u7684\u8bbe\u5907\u4f1a\u663e\u793a\u5728\u4e0b\u65b9\u3002 \n\n\u5982\u679c\u6ca1\u6709\u53d1\u73b0\u8bbe\u5907\u6216\u9047\u5230\u4efb\u4f55\u95ee\u9898\uff0c\u8bf7\u5c1d\u8bd5\u6307\u5b9a\u8bbe\u5907 IP \u5730\u5740\u3002 \n\n {devices}", "title": "\u8bbe\u7f6e\u65b0\u7684 Apple TV" } diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index bf24f2fdac5..e92c826faaa 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.9.4"], + "requirements": ["apprise==0.9.5.1"], "codeowners": ["@caronc"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 394f8844adb..4c46a7aa5eb 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -1,8 +1,15 @@ """Support for AquaLogic sensors.""" +from __future__ import annotations + +from dataclasses import dataclass 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, DEVICE_CLASS_TEMPERATURE, @@ -16,40 +23,88 @@ import homeassistant.helpers.config_validation as cv from . import DOMAIN, UPDATE_TOPIC -TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] -PERCENT_UNITS = [PERCENTAGE, PERCENTAGE] -SALT_UNITS = ["g/L", "PPM"] -WATT_UNITS = [POWER_WATT, POWER_WATT] -NO_UNITS = [None, None] -# 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, 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], -} +@dataclass +class AquaLogicSensorEntityDescription(SensorEntityDescription): + """Describes AquaLogic sensor entity.""" + + unit_metric: str | None = None + unit_imperial: str | None = None + + +# keys correspond to property names in aqualogic.core.AquaLogic +SENSOR_TYPES: tuple[AquaLogicSensorEntityDescription, ...] = ( + AquaLogicSensorEntityDescription( + key="air_temp", + name="Air Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="pool_temp", + name="Pool Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + icon="mdi:oil-temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="spa_temp", + name="Spa Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + icon="mdi:oil-temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + AquaLogicSensorEntityDescription( + key="pool_chlorinator", + name="Pool Chlorinator", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="spa_chlorinator", + name="Spa Chlorinator", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="salt_level", + name="Salt Level", + unit_metric="g/L", + unit_imperial="PPM", + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="pump_speed", + name="Pump Speed", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + icon="mdi:speedometer", + ), + AquaLogicSensorEntityDescription( + key="pump_power", + name="Pump Power", + unit_metric=POWER_WATT, + unit_imperial=POWER_WATT, + icon="mdi:gauge", + ), + AquaLogicSensorEntityDescription( + key="status", + name="Status", + icon="mdi:alert", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -57,26 +112,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" - sensors = [] - processor = hass.data[DOMAIN] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(AquaLogicSensor(processor, sensor_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - async_add_entities(sensors) + entities = [ + AquaLogicSensor(processor, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + + async_add_entities(entities) class AquaLogicSensor(SensorEntity): """Sensor implementation for the AquaLogic component.""" + entity_description: AquaLogicSensorEntityDescription _attr_should_poll = False - def __init__(self, processor, sensor_type): + def __init__(self, processor, description: AquaLogicSensorEntityDescription): """Initialize sensor.""" + self.entity_description = description self._processor = processor - self._type = sensor_type - self._attr_name = f"AquaLogic {SENSOR_TYPES[sensor_type][0]}" - self._attr_icon = SENSOR_TYPES[sensor_type][2] + self._attr_name = f"AquaLogic {description.name}" async def async_added_to_hass(self): """Register callbacks.""" @@ -92,11 +150,15 @@ class AquaLogicSensor(SensorEntity): panel = self._processor.panel if panel is not None: if panel.is_metric: - self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][0] + self._attr_native_unit_of_measurement = ( + self.entity_description.unit_metric + ) else: - self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][1] + self._attr_native_unit_of_measurement = ( + self.entity_description.unit_imperial + ) - self._attr_native_value = getattr(panel, self._type) + self._attr_native_value = getattr(panel, self.entity_description.key) self.async_write_ha_state() else: self._attr_native_unit_of_measurement = None diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index 7bf7a06d851..ed9308a89c6 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( ATTR_ENTITY_ID, @@ -57,10 +60,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] job = HassJob(action) if config[CONF_TYPE] == "turn_on": diff --git a/homeassistant/components/arcam_fmj/translations/fr.json b/homeassistant/components/arcam_fmj/translations/fr.json index 511d9e98a50..938e9ab7b5d 100644 --- a/homeassistant/components/arcam_fmj/translations/fr.json +++ b/homeassistant/components/arcam_fmj/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil \u00e9tait d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "error": { diff --git a/homeassistant/components/arcam_fmj/translations/hu.json b/homeassistant/components/arcam_fmj/translations/hu.json index 9539ad39bed..c7532f24b76 100644 --- a/homeassistant/components/arcam_fmj/translations/hu.json +++ b/homeassistant/components/arcam_fmj/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "error": { @@ -16,10 +16,10 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, - "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t." + "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z hosztnev\u00e9t vagy c\u00edm\u00e9t" } } }, diff --git a/homeassistant/components/arcam_fmj/translations/id.json b/homeassistant/components/arcam_fmj/translations/id.json index 96b10140948..cee43cbb4e9 100644 --- a/homeassistant/components/arcam_fmj/translations/id.json +++ b/homeassistant/components/arcam_fmj/translations/id.json @@ -5,7 +5,7 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung", "cannot_connect": "Gagal terhubung" }, - "flow_title": "Arcam FMJ di {host}", + "flow_title": "{host}", "step": { "confirm": { "description": "Ingin menambahkan Arcam FMJ `{host}` ke Home Assistant?" diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index af7f3b05e33..c87ed85b759 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -81,7 +81,7 @@ async def async_setup(hass, config): options = {} mode = conf.get(CONF_MODE, MODE_ROUTER) for name, value in conf.items(): - if name in ([CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]): + if name in [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]: if name == CONF_REQUIRE_IP and mode != MODE_AP: continue options[name] = value diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 9acea7ba762..7e89ea07dbd 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -1,9 +1,10 @@ """Represent the AsusWrt router.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Any, Callable +from typing import Any from aioasuswrt.asuswrt import AsusWrt @@ -373,7 +374,7 @@ class AsusWrtRouter: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): - if name in (CONF_REQ_RELOAD): + if name in CONF_REQ_RELOAD: old_opt = self._options.get(name) if not old_opt or old_opt != new_opt: req_reload = True diff --git a/homeassistant/components/asuswrt/translations/he.json b/homeassistant/components/asuswrt/translations/he.json index 7b859e5af0c..2d2cebaa7e3 100644 --- a/homeassistant/components/asuswrt/translations/he.json +++ b/homeassistant/components/asuswrt/translations/he.json @@ -8,6 +8,7 @@ "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd", "pwd_and_ssh": "\u05e1\u05e4\u05e7 \u05e8\u05e7 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d5 \u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH", "pwd_or_ssh": "\u05d0\u05e0\u05d0 \u05e1\u05e4\u05e7 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d5 \u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH", + "ssh_not_file": "\u05e7\u05d5\u05d1\u05e5 \u05de\u05e4\u05ea\u05d7 SSH \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { diff --git a/homeassistant/components/asuswrt/translations/hu.json b/homeassistant/components/asuswrt/translations/hu.json index 891150c1038..ff64372f1b0 100644 --- a/homeassistant/components/asuswrt/translations/hu.json +++ b/homeassistant/components/asuswrt/translations/hu.json @@ -14,7 +14,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "mode": "M\u00f3d", "name": "N\u00e9v", "password": "Jelsz\u00f3", diff --git a/homeassistant/components/asuswrt/translations/ru.json b/homeassistant/components/asuswrt/translations/ru.json index a2090b1faf6..f77fcb4fb3a 100644 --- a/homeassistant/components/asuswrt/translations/ru.json +++ b/homeassistant/components/asuswrt/translations/ru.json @@ -32,7 +32,7 @@ "step": { "init": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "dnsmasq": "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435 \u0444\u0430\u0439\u043b\u043e\u0432 dnsmasq.leases", "interface": "\u0418\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441, \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0443 (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, eth0, eth1 \u0438 \u0442. \u0434.)", "require_ip": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u0441 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u043c (\u0434\u043b\u044f \u0440\u0435\u0436\u0438\u043c\u0430 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", diff --git a/homeassistant/components/atag/translations/fr.json b/homeassistant/components/atag/translations/fr.json index a0f4b9f3808..c42b64a16c8 100644 --- a/homeassistant/components/atag/translations/fr.json +++ b/homeassistant/components/atag/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Un seul appareil Atag peut \u00eatre ajout\u00e9 \u00e0 Home Assistant" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Se connecter \u00e0 l'appareil" diff --git a/homeassistant/components/atag/translations/hu.json b/homeassistant/components/atag/translations/hu.json index 8c3b4a055b0..aa605923dfd 100644 --- a/homeassistant/components/atag/translations/hu.json +++ b/homeassistant/components/atag/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 27a115a0823..9a38cd1e301 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -1,17 +1,30 @@ """Support for August binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timedelta import logging +from typing import cast -from yalexs.activity import ACTION_DOORBELL_CALL_MISSED, SOURCE_PUBNUB, ActivityType +from yalexs.activity import ( + ACTION_DOORBELL_CALL_MISSED, + SOURCE_PUBNUB, + Activity, + ActivityType, +) +from yalexs.doorbell import DoorbellDetail from yalexs.lock import LockDoorStatus from yalexs.util import update_lock_detail_from_activity +from homeassistant.components.august import AugustData from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_DOOR, DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import callback from homeassistant.helpers.event import async_call_later @@ -27,7 +40,7 @@ TIME_TO_RECHECK_DETECTION = timedelta( ) -def _retrieve_online_state(data, detail): +def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool: """Get the latest state of the sensor.""" # The doorbell will go into standby mode when there is no motion # for a short while. It will wake by itself when needed so we need @@ -36,7 +49,7 @@ def _retrieve_online_state(data, detail): return detail.is_online or detail.is_standby -def _retrieve_motion_state(data, detail): +def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_MOTION} ) @@ -47,7 +60,7 @@ def _retrieve_motion_state(data, detail): return _activity_time_based_state(latest) -def _retrieve_ding_state(data, detail): +def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool: latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_DING} ) @@ -64,34 +77,62 @@ def _retrieve_ding_state(data, detail): return _activity_time_based_state(latest) -def _activity_time_based_state(latest): +def _activity_time_based_state(latest: Activity) -> bool: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION return start <= _native_datetime() <= end -def _native_datetime(): +def _native_datetime() -> datetime: """Return time in the format august uses without timezone.""" return datetime.now() -SENSOR_NAME = 0 -SENSOR_DEVICE_CLASS = 1 -SENSOR_STATE_PROVIDER = 2 -SENSOR_STATE_IS_TIME_BASED = 3 +@dataclass +class AugustRequiredKeysMixin: + """Mixin for required keys.""" -# sensor_type: [name, device_class, state_provider, is_time_based] -SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True], - "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True], - "doorbell_online": [ - "Online", - DEVICE_CLASS_CONNECTIVITY, - _retrieve_online_state, - False, - ], -} + value_fn: Callable[[AugustData, DoorbellDetail], bool] + is_time_based: bool + + +@dataclass +class AugustBinarySensorEntityDescription( + BinarySensorEntityDescription, AugustRequiredKeysMixin +): + """Describes August binary_sensor entity.""" + + +SENSOR_TYPE_DOOR = BinarySensorEntityDescription( + key="door_open", + name="Open", +) + + +SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = ( + AugustBinarySensorEntityDescription( + key="doorbell_ding", + name="Ding", + device_class=DEVICE_CLASS_OCCUPANCY, + value_fn=_retrieve_ding_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_motion", + name="Motion", + device_class=DEVICE_CLASS_MOTION, + value_fn=_retrieve_motion_state, + is_time_based=True, + ), + AugustBinarySensorEntityDescription( + key="doorbell_online", + name="Online", + device_class=DEVICE_CLASS_CONNECTIVITY, + value_fn=_retrieve_online_state, + is_time_based=False, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -109,16 +150,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): continue _LOGGER.debug("Adding sensor class door for %s", door.device_name) - entities.append(AugustDoorBinarySensor(data, "door_open", door)) + entities.append(AugustDoorBinarySensor(data, door, SENSOR_TYPE_DOOR)) for doorbell in data.doorbells: - for sensor_type, sensor in SENSOR_TYPES_DOORBELL.items(): + for description in SENSOR_TYPES_DOORBELL: _LOGGER.debug( "Adding doorbell sensor class %s for %s", - sensor[SENSOR_DEVICE_CLASS], + description.device_class, doorbell.device_name, ) - entities.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) + entities.append(AugustDoorbellBinarySensor(data, doorbell, description)) async_add_entities(entities) @@ -128,14 +169,16 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): _attr_device_class = DEVICE_CLASS_DOOR - def __init__(self, data, sensor_type, device): + def __init__(self, data, device, description: BinarySensorEntityDescription): """Initialize the sensor.""" super().__init__(data, device) + self.entity_description = description 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._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = ( + f"{self._device_id}_{cast(str, description.name).lower()}" + ) self._update_from_data() @callback @@ -164,41 +207,27 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August binary sensor.""" - def __init__(self, data, sensor_type, device): + entity_description: AugustBinarySensorEntityDescription + + def __init__(self, data, device, description: AugustBinarySensorEntityDescription): """Initialize the sensor.""" super().__init__(data, device) + self.entity_description = description self._check_for_off_update_listener = None self._data = data - self._sensor_type = sensor_type - self._attr_device_class = self._sensor_config[SENSOR_DEVICE_CLASS] - self._attr_name = f"{device.device_name} {self._sensor_config[SENSOR_NAME]}" + self._attr_name = f"{device.device_name} {description.name}" self._attr_unique_id = ( - f"{self._device_id}_{self._sensor_config[SENSOR_NAME].lower()}" + f"{self._device_id}_{cast(str, description.name).lower()}" ) self._update_from_data() - @property - def _sensor_config(self): - """Return the config for the sensor.""" - return SENSOR_TYPES_DOORBELL[self._sensor_type] - - @property - def _state_provider(self): - """Return the state provider for the binary sensor.""" - return self._sensor_config[SENSOR_STATE_PROVIDER] - - @property - def _is_time_based(self): - """Return true of false if the sensor is time based.""" - return self._sensor_config[SENSOR_STATE_IS_TIME_BASED] - @callback def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - self._attr_is_on = self._state_provider(self._data, self._detail) + self._attr_is_on = self.entity_description.value_fn(self._data, self._detail) - if self._is_time_based: + if self.entity_description.is_time_based: self._attr_available = _retrieve_online_state(self._data, self._detail) self._schedule_update_to_recheck_turn_off_sensor() else: diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index b6d93d3b3b1..b6fa767edb7 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,9 +1,21 @@ """Support for August sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Generic, TypeVar from yalexs.activity import ActivityType +from yalexs.keypad import KeypadDetail +from yalexs.lock import LockDetail -from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, SensorEntity +from homeassistant.components.august import AugustData +from homeassistant.components.sensor import ( + DEVICE_CLASS_BATTERY, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.helpers.entity_registry import async_get_registry @@ -26,20 +38,44 @@ from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) -def _retrieve_device_battery_state(detail): +def _retrieve_device_battery_state(detail: LockDetail) -> int: """Get the latest state of the sensor.""" return detail.battery_level -def _retrieve_linked_keypad_battery_state(detail): +def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: """Get the latest state of the sensor.""" return detail.battery_percentage -SENSOR_TYPES_BATTERY = { - "device_battery": {"state_provider": _retrieve_device_battery_state}, - "linked_keypad_battery": {"state_provider": _retrieve_linked_keypad_battery_state}, -} +T = TypeVar("T", LockDetail, KeypadDetail) + + +@dataclass +class AugustRequiredKeysMixin(Generic[T]): + """Mixin for required keys.""" + + value_fn: Callable[[T], int | None] + + +@dataclass +class AugustSensorEntityDescription( + SensorEntityDescription, AugustRequiredKeysMixin[T] +): + """Describes August sensor entity.""" + + +SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( + key="device_battery", + name="Battery", + value_fn=_retrieve_device_battery_state, +) + +SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( + key="linked_keypad_battery", + name="Battery", + value_fn=_retrieve_linked_keypad_battery_state, +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -60,9 +96,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): operation_sensors.append(device) for device in batteries["device_battery"]: - state_provider = SENSOR_TYPES_BATTERY["device_battery"]["state_provider"] detail = data.get_device_detail(device.device_id) - if detail is None or state_provider(detail) is None: + if detail is None or SENSOR_TYPE_DEVICE_BATTERY.value_fn(detail) is None: _LOGGER.debug( "Not adding battery sensor for %s because it is not present", device.device_name, @@ -72,7 +107,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Adding battery sensor for %s", device.device_name, ) - entities.append(AugustBatterySensor(data, "device_battery", device, device)) + entities.append( + AugustBatterySensor[LockDetail]( + data, device, device, SENSOR_TYPE_DEVICE_BATTERY + ) + ) for device in batteries["linked_keypad_battery"]: detail = data.get_device_detail(device.device_id) @@ -87,8 +126,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Adding keypad battery sensor for %s", device.device_name, ) - keypad_battery_sensor = AugustBatterySensor( - data, "linked_keypad_battery", detail.keypad, device + keypad_battery_sensor = AugustBatterySensor[KeypadDetail]( + data, detail.keypad, device, SENSOR_TYPE_KEYPAD_BATTERY ) entities.append(keypad_battery_sensor) migrate_unique_id_devices.append(keypad_battery_sensor) @@ -204,29 +243,35 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): return f"{self._device_id}_lock_operator" -class AugustBatterySensor(AugustEntityMixin, SensorEntity): +class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[T]): """Representation of an August sensor.""" + entity_description: AugustSensorEntityDescription[T] _attr_device_class = DEVICE_CLASS_BATTERY _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, data, sensor_type, device, old_device): + def __init__( + self, + data: AugustData, + device, + old_device, + description: AugustSensorEntityDescription[T], + ): """Initialize the sensor.""" super().__init__(data, device) - self._sensor_type = sensor_type + self.entity_description = description self._old_device = old_device - self._attr_name = f"{device.device_name} Battery" - self._attr_unique_id = f"{self._device_id}_{sensor_type}" + self._attr_name = f"{device.device_name} {description.name}" + self._attr_unique_id = f"{self._device_id}_{description.key}" self._update_from_data() @callback def _update_from_data(self): """Get the latest state of the sensor.""" - state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - self._attr_native_value = state_provider(self._detail) + self._attr_native_value = self.entity_description.value_fn(self._detail) self._attr_available = self._attr_native_value is not None @property def old_unique_id(self) -> str: """Get the old unique id of the device sensor.""" - return f"{self._old_device.device_id}_{self._sensor_type}" + return f"{self._old_device.device_id}_{self.entity_description.key}" diff --git a/homeassistant/components/august/translations/ca.json b/homeassistant/components/august/translations/ca.json index f0b1fa43c3d..ee9a860b1d1 100644 --- a/homeassistant/components/august/translations/ca.json +++ b/homeassistant/components/august/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/august/translations/fr.json b/homeassistant/components/august/translations/fr.json index aebb72a76ed..8b61f7b3267 100644 --- a/homeassistant/components/august/translations/fr.json +++ b/homeassistant/components/august/translations/fr.json @@ -5,8 +5,8 @@ "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/august/translations/hu.json b/homeassistant/components/august/translations/hu.json index aeaef514e71..22e16dda305 100644 --- a/homeassistant/components/august/translations/hu.json +++ b/homeassistant/components/august/translations/hu.json @@ -14,7 +14,7 @@ "data": { "password": "Jelsz\u00f3" }, - "description": "Add meg a(z) {username} jelszav\u00e1t.", + "description": "Adja meg a(z) {username} jelszav\u00e1t.", "title": "August fi\u00f3k \u00fajrahiteles\u00edt\u00e9se" }, "user_validate": { diff --git a/homeassistant/components/aurora/translations/fr.json b/homeassistant/components/aurora/translations/fr.json index 473ecefdbd9..aa334f074c6 100644 --- a/homeassistant/components/aurora/translations/fr.json +++ b/homeassistant/components/aurora/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 7381be5e9de..49c18b4737a 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -117,6 +117,7 @@ Result will be a long-lived access token: from __future__ import annotations from datetime import timedelta +from http import HTTPStatus import uuid from aiohttp import web @@ -133,7 +134,7 @@ from homeassistant.components.http.auth import async_sign_path from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_OK +from homeassistant.const import HTTP_OK from homeassistant.core import HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -259,11 +260,13 @@ class TokenView(HomeAssistantView): return await self._async_handle_refresh_token(hass, data, request.remote) return self.json( - {"error": "unsupported_grant_type"}, status_code=HTTP_BAD_REQUEST + {"error": "unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST ) async def _async_handle_revoke_token(self, hass, data): """Handle revoke token request.""" + # pylint: disable=no-self-use + # OAuth 2.0 Token Revocation [RFC7009] # 2.2 The authorization server responds with HTTP status code 200 # if the token has been revoked successfully or if the client @@ -287,7 +290,7 @@ class TokenView(HomeAssistantView): if client_id is None or not indieauth.verify_client_id(client_id): return self.json( {"error": "invalid_request", "error_description": "Invalid client id"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) code = data.get("code") @@ -295,7 +298,7 @@ class TokenView(HomeAssistantView): if code is None: return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) credential = self._retrieve_auth(client_id, RESULT_TYPE_CREDENTIALS, code) @@ -303,7 +306,7 @@ class TokenView(HomeAssistantView): if credential is None or not isinstance(credential, Credentials): return self.json( {"error": "invalid_request", "error_description": "Invalid code"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) user = await hass.auth.async_get_or_create_user(credential) @@ -311,7 +314,7 @@ class TokenView(HomeAssistantView): if not user.is_active: return self.json( {"error": "access_denied", "error_description": "User is not active"}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) refresh_token = await hass.auth.async_create_refresh_token( @@ -324,7 +327,7 @@ class TokenView(HomeAssistantView): except InvalidAuthError as exc: return self.json( {"error": "access_denied", "error_description": str(exc)}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) return self.json( @@ -344,21 +347,27 @@ class TokenView(HomeAssistantView): if client_id is not None and not indieauth.verify_client_id(client_id): return self.json( {"error": "invalid_request", "error_description": "Invalid client id"}, - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, ) token = data.get("refresh_token") if token is None: - return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: - return self.json({"error": "invalid_grant"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_grant"}, status_code=HTTPStatus.BAD_REQUEST + ) if refresh_token.client_id != client_id: - return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) try: access_token = hass.auth.async_create_access_token( @@ -367,7 +376,7 @@ class TokenView(HomeAssistantView): except InvalidAuthError as exc: return self.json( {"error": "access_denied", "error_description": str(exc)}, - status_code=HTTP_FORBIDDEN, + status_code=HTTPStatus.FORBIDDEN, ) return self.json( @@ -402,7 +411,7 @@ class LinkUserView(HomeAssistantView): ) if credentials is None: - return self.json_message("Invalid code", status_code=HTTP_BAD_REQUEST) + return self.json_message("Invalid code", status_code=HTTPStatus.BAD_REQUEST) await hass.auth.async_link_user(user, credentials) return self.json_message("User linked") diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index b01e6e0c01e..f15eeee2f16 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -66,6 +66,7 @@ associate with an credential if "type" set to "link_user" in "version": 1 } """ +from http import HTTPStatus from ipaddress import ip_address from aiohttp import web @@ -80,11 +81,7 @@ from homeassistant.components.http.ban import ( ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import ( - HTTP_BAD_REQUEST, - HTTP_METHOD_NOT_ALLOWED, - HTTP_NOT_FOUND, -) +from homeassistant.const import HTTP_METHOD_NOT_ALLOWED from . import indieauth @@ -109,7 +106,7 @@ class AuthProvidersView(HomeAssistantView): if not hass.components.onboarding.async_is_user_onboarded(): return self.json_message( message="Onboarding not finished", - status_code=HTTP_BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, message_code="onboarding_required", ) @@ -157,6 +154,7 @@ class LoginFlowIndexView(HomeAssistantView): async def get(self, request): """Do not allow index of flows in progress.""" + # pylint: disable=no-self-use return web.Response(status=HTTP_METHOD_NOT_ALLOWED) @RequestDataValidator( @@ -176,7 +174,7 @@ class LoginFlowIndexView(HomeAssistantView): request.app["hass"], data["client_id"], data["redirect_uri"] ): return self.json_message( - "invalid client id or redirect uri", HTTP_BAD_REQUEST + "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) if isinstance(data["handler"], list): @@ -193,9 +191,11 @@ class LoginFlowIndexView(HomeAssistantView): }, ) except data_entry_flow.UnknownHandler: - return self.json_message("Invalid handler specified", HTTP_NOT_FOUND) + return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) except data_entry_flow.UnknownStep: - return self.json_message("Handler does not support init", HTTP_BAD_REQUEST) + return self.json_message( + "Handler does not support init", HTTPStatus.BAD_REQUEST + ) if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: await process_success_login(request) @@ -220,7 +220,7 @@ class LoginFlowResourceView(HomeAssistantView): async def get(self, request): """Do not allow getting status of a flow in progress.""" - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) @RequestDataValidator(vol.Schema({"client_id": str}, extra=vol.ALLOW_EXTRA)) @log_invalid_auth @@ -229,7 +229,7 @@ class LoginFlowResourceView(HomeAssistantView): client_id = data.pop("client_id") if not indieauth.verify_client_id(client_id): - return self.json_message("Invalid client id", HTTP_BAD_REQUEST) + return self.json_message("Invalid client id", HTTPStatus.BAD_REQUEST) try: # do not allow change ip during login flow @@ -237,13 +237,15 @@ class LoginFlowResourceView(HomeAssistantView): if flow["flow_id"] == flow_id and flow["context"][ "ip_address" ] != ip_address(request.remote): - return self.json_message("IP address changed", HTTP_BAD_REQUEST) + return self.json_message( + "IP address changed", HTTPStatus.BAD_REQUEST + ) result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("User input malformed", HTTP_BAD_REQUEST) + return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200 @@ -265,6 +267,6 @@ class LoginFlowResourceView(HomeAssistantView): try: self._flow_mgr.async_abort(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) return self.json_message("Flow aborted") diff --git a/homeassistant/components/auth/translations/fi.json b/homeassistant/components/auth/translations/fi.json index 92e4f03c0f9..ca174d81e6e 100644 --- a/homeassistant/components/auth/translations/fi.json +++ b/homeassistant/components/auth/translations/fi.json @@ -12,6 +12,11 @@ "title": "Ilmoita kertaluonteinen salasana" }, "totp": { + "step": { + "init": { + "title": "M\u00e4\u00e4rit\u00e4 kaksivaiheinen todennus TOTP:n avulla" + } + }, "title": "TOTP" } } diff --git a/homeassistant/components/auth/translations/hu.json b/homeassistant/components/auth/translations/hu.json index 5e7b1835093..47ecf846e0f 100644 --- a/homeassistant/components/auth/translations/hu.json +++ b/homeassistant/components/auth/translations/hu.json @@ -9,11 +9,11 @@ }, "step": { "init": { - "description": "K\u00e9rlek, v\u00e1lassz egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", + "description": "K\u00e9rem, v\u00e1lasszon egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", "title": "\u00c1ll\u00edtsa be az \u00e9rtes\u00edt\u00e9si \u00f6sszetev\u0151 \u00e1ltal megadott egyszeri jelsz\u00f3t" }, "setup": { - "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rlek, add meg al\u00e1bb:", + "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rem, adja meg al\u00e1bb:", "title": "Be\u00e1ll\u00edt\u00e1s ellen\u0151rz\u00e9se" } }, @@ -21,11 +21,11 @@ }, "totp": { "error": { - "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r." + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1lja \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy a Home Assistant rendszer\u00e9nek \u00f3r\u00e1ja pontosan j\u00e1r." }, "step": { "init": { - "description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", + "description": "Ahhoz, hogy haszn\u00e1lhassa a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkennelje be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3j\u00e1val. Ha m\u00e9g nincs ilyenje, akkor aj\u00e1nljuk figyelm\u00e9be a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n adja meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6zne a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edtson egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val" } }, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 5e1b53c535e..24090b79fa8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, Awaitable, Callable, Dict, cast +from typing import Any, Awaitable, Callable, Dict, TypedDict, cast import voluptuous as vol from voluptuous.humanize import humanize_error @@ -106,6 +106,23 @@ _LOGGER = logging.getLogger(__name__) AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] +class AutomationTriggerData(TypedDict): + """Automation trigger data.""" + + id: str + idx: str + + +class AutomationTriggerInfo(TypedDict): + """Information about automation trigger.""" + + domain: str + name: str + home_assistant_start: bool + variables: TemplateVarsType + trigger_data: AutomationTriggerData + + @bind_hass def is_on(hass, entity_id): """ diff --git a/homeassistant/components/automation/translations/hu.json b/homeassistant/components/automation/translations/hu.json index 85640af23ba..559523b1b12 100644 --- a/homeassistant/components/automation/translations/hu.json +++ b/homeassistant/components/automation/translations/hu.json @@ -5,5 +5,5 @@ "on": "Be" } }, - "title": "Automatiz\u00e1l\u00e1s" + "title": "Automatizmus" } \ No newline at end of file diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 8199c3881c9..39853dab9de 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -74,6 +74,7 @@ class AwairDataUpdateCoordinator(DataUpdateCoordinator): async def _fetch_air_data(self, device): """Fetch latest air quality data.""" + # pylint: disable=no-self-use LOGGER.debug("Fetching data for %s", device.uuid) air_data = await device.air_data_latest() LOGGER.debug(air_data) diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 1841a167a50..4968e86bcf5 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,4 +1,5 @@ """Constants for the Awair component.""" +from __future__ import annotations from dataclasses import dataclass from datetime import timedelta @@ -6,9 +7,8 @@ import logging from python_awair.devices import AwairDevice +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -36,10 +36,6 @@ API_VOC = "volatile_organic_compounds" ATTRIBUTION = "Awair air quality sensor" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" -ATTR_UNIQUE_ID = "unique_id" - DOMAIN = "awair" DUST_ALIASES = [API_PM25, API_PM10] @@ -48,71 +44,89 @@ LOGGER = logging.getLogger(__package__) UPDATE_INTERVAL = timedelta(minutes=5) -SENSOR_TYPES = { - API_SCORE: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Awair score", - ATTR_UNIQUE_ID: "score", # matches legacy format - }, - API_HUMID: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Humidity", - ATTR_UNIQUE_ID: "HUMID", # matches legacy format - }, - API_LUX: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, - ATTR_ICON: None, - ATTR_UNIT: LIGHT_LUX, - ATTR_LABEL: "Illuminance", - ATTR_UNIQUE_ID: "illuminance", - }, - API_SPL_A: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:ear-hearing", - ATTR_UNIT: SOUND_PRESSURE_WEIGHTED_DBA, - ATTR_LABEL: "Sound level", - ATTR_UNIQUE_ID: "sound_level", - }, - API_VOC: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_LABEL: "Volatile organic compounds", - ATTR_UNIQUE_ID: "VOC", # matches legacy format - }, - API_TEMP: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_UNIT: TEMP_CELSIUS, - ATTR_LABEL: "Temperature", - ATTR_UNIQUE_ID: "TEMP", # matches legacy format - }, - API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM2.5", - ATTR_UNIQUE_ID: "PM25", # matches legacy format - }, - API_PM10: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM10", - ATTR_UNIQUE_ID: "PM10", # matches legacy format - }, - API_CO2: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO2, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_LABEL: "Carbon dioxide", - ATTR_UNIQUE_ID: "CO2", # matches legacy format - }, -} + +@dataclass +class AwairRequiredKeysMixin: + """Mixinf for required keys.""" + + unique_id_tag: str + + +@dataclass +class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): + """Describes Awair sensor entity.""" + + +SENSOR_TYPE_SCORE = AwairSensorEntityDescription( + key=API_SCORE, + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + name="Awair score", + unique_id_tag="score", # matches legacy format +) + +SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_HUMID, + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + unique_id_tag="HUMID", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + unique_id_tag="illuminance", + ), + AwairSensorEntityDescription( + key=API_SPL_A, + icon="mdi:ear-hearing", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + name="Sound level", + unique_id_tag="sound_level", + ), + AwairSensorEntityDescription( + key=API_VOC, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="Volatile organic compounds", + unique_id_tag="VOC", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + unique_id_tag="TEMP", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_CO2, + device_class=DEVICE_CLASS_CO2, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="Carbon dioxide", + unique_id_tag="CO2", # matches legacy format + ), +) + +SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_PM25, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM2.5", + unique_id_tag="PM25", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_PM10, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM10", + unique_id_tag="PM10", # matches legacy format + ), +) @dataclass diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 3b46d3b2317..80591e36f2d 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_ACCESS_TOKEN +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv @@ -22,15 +22,14 @@ from .const import ( API_SCORE, API_TEMP, API_VOC, - ATTR_ICON, - ATTR_LABEL, - ATTR_UNIQUE_ID, - ATTR_UNIT, ATTRIBUTION, DOMAIN, DUST_ALIASES, LOGGER, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, + AwairSensorEntityDescription, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -60,16 +59,20 @@ async def async_setup_entry( ): """Set up Awair sensor entity based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] + entities = [] data: list[AwairResult] = coordinator.data.values() for result in data: if result.air_data: - sensors.append(AwairSensor(API_SCORE, result.device, coordinator)) + entities.append(AwairSensor(result.device, coordinator, SENSOR_TYPE_SCORE)) device_sensors = result.air_data.sensors.keys() - for sensor in device_sensors: - if sensor in SENSOR_TYPES: - sensors.append(AwairSensor(sensor, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in (*SENSOR_TYPES, *SENSOR_TYPES_DUST) + if description.key in device_sensors + ] + ) # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only # present on first-gen devices in lieu of separate pm2.5/pm10 sensors. @@ -78,45 +81,53 @@ async def async_setup_entry( # that data - because we can't really tell what kind of particles the # "DUST" sensor actually detected. However, it's still useful data. if API_DUST in device_sensors: - for alias_kind in DUST_ALIASES: - sensors.append(AwairSensor(alias_kind, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in SENSOR_TYPES_DUST + ] + ) - async_add_entities(sensors) + async_add_entities(entities) class AwairSensor(CoordinatorEntity, SensorEntity): """Defines an Awair sensor entity.""" + entity_description: AwairSensorEntityDescription + def __init__( self, - kind: str, device: AwairDevice, coordinator: AwairDataUpdateCoordinator, + description: AwairSensorEntityDescription, ) -> None: """Set up an individual AwairSensor.""" super().__init__(coordinator) - self._kind = kind + self.entity_description = description self._device = device @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the sensor.""" - name = SENSOR_TYPES[self._kind][ATTR_LABEL] if self._device.name: - name = f"{self._device.name} {name}" + return f"{self._device.name} {self.entity_description.name}" - return name + return self.entity_description.name @property def unique_id(self) -> str: """Return the uuid as the unique_id.""" - unique_id_tag = SENSOR_TYPES[self._kind][ATTR_UNIQUE_ID] + unique_id_tag = self.entity_description.unique_id_tag # This integration used to create a sensor that was labelled as a "PM2.5" # sensor for first-gen Awair devices, but its unique_id reflected the truth: # under the hood, it was a "DUST" sensor. So we preserve that specific unique_id # for users with first-gen devices that are upgrading. - if self._kind == API_PM25 and API_DUST in self._air_data.sensors: + if ( + self.entity_description.key == API_PM25 + and API_DUST in self._air_data.sensors + ): unique_id_tag = "DUST" return f"{self._device.uuid}_{unique_id_tag}" @@ -127,16 +138,17 @@ class AwairSensor(CoordinatorEntity, SensorEntity): # If the last update was successful... if self.coordinator.last_update_success and self._air_data: # and the results included our sensor type... - if self._kind in self._air_data.sensors: + sensor_type = self.entity_description.key + if sensor_type in self._air_data.sensors: # then we are available. return True # or, we're a dust alias - if self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + if sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: return True # or we are API_SCORE - if self._kind == API_SCORE: + if sensor_type == API_SCORE: # then we are available. return True @@ -147,38 +159,24 @@ class AwairSensor(CoordinatorEntity, SensorEntity): def native_value(self) -> float: """Return the state, rounding off to reasonable values.""" state: float + sensor_type = self.entity_description.key # Special-case for "SCORE", which we treat as the AQI - if self._kind == API_SCORE: + if sensor_type == API_SCORE: state = self._air_data.score - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: state = self._air_data.sensors.dust else: - state = self._air_data.sensors[self._kind] + state = self._air_data.sensors[sensor_type] - if self._kind == API_VOC or self._kind == API_SCORE: + if sensor_type in {API_VOC, API_SCORE}: return round(state) - if self._kind == API_TEMP: + if sensor_type == API_TEMP: return round(state, 1) return round(state, 2) - @property - def icon(self) -> str: - """Return the icon.""" - return SENSOR_TYPES[self._kind][ATTR_ICON] - - @property - def device_class(self) -> str: - """Return the device_class.""" - return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self._kind][ATTR_UNIT] - @property def extra_state_attributes(self) -> dict: """Return the Awair Index alongside state attributes. @@ -201,10 +199,11 @@ class AwairSensor(CoordinatorEntity, SensorEntity): https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ + sensor_type = self.entity_description.key attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - if self._kind in self._air_data.indices: - attrs["awair_index"] = abs(self._air_data.indices[self._kind]) - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.indices: + if sensor_type in self._air_data.indices: + attrs["awair_index"] = abs(self._air_data.indices[sensor_type]) + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.indices: attrs["awair_index"] = abs(self._air_data.indices.dust) return attrs diff --git a/homeassistant/components/awair/translations/ca.json b/homeassistant/components/awair/translations/ca.json index 2e75af9e744..ac69e06df1e 100644 --- a/homeassistant/components/awair/translations/ca.json +++ b/homeassistant/components/awair/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_devices_found": "No s'han trobat dispositius a la xarxa", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, diff --git a/homeassistant/components/awair/translations/fr.json b/homeassistant/components/awair/translations/fr.json index dd90f940977..65d550b52a6 100644 --- a/homeassistant/components/awair/translations/fr.json +++ b/homeassistant/components/awair/translations/fr.json @@ -3,11 +3,11 @@ "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_access_token": "Jeton d'acc\u00e8s non valide", - "unknown": "Erreur d'API Awair inconnue." + "unknown": "Erreur inattendue" }, "step": { "reauth": { diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index f465186a95b..62187becd37 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -15,7 +15,7 @@ "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "email": "E-mail" }, - "description": "Add meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." + "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." }, "user": { "data": { diff --git a/homeassistant/components/axis/translations/fr.json b/homeassistant/components/axis/translations/fr.json index ed4113d02e2..ea3f93feb50 100644 --- a/homeassistant/components/axis/translations/fr.json +++ b/homeassistant/components/axis/translations/fr.json @@ -7,7 +7,7 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, @@ -15,7 +15,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "username": "Nom d'utilisateur" diff --git a/homeassistant/components/axis/translations/hu.json b/homeassistant/components/axis/translations/hu.json index 709de5851ad..0cddf167437 100644 --- a/homeassistant/components/axis/translations/hu.json +++ b/homeassistant/components/axis/translations/hu.json @@ -7,15 +7,15 @@ }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, - "flow_title": "Axis eszk\u00f6z: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/azure_devops/translations/ca.json b/homeassistant/components/azure_devops/translations/ca.json index b3eb6e4eb8e..e6811e54078 100644 --- a/homeassistant/components/azure_devops/translations/ca.json +++ b/homeassistant/components/azure_devops/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/azure_devops/translations/fr.json b/homeassistant/components/azure_devops/translations/fr.json index 5e62d54ec1d..27513074046 100644 --- a/homeassistant/components/azure_devops/translations/fr.json +++ b/homeassistant/components/azure_devops/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/azure_devops/translations/id.json b/homeassistant/components/azure_devops/translations/id.json index 42292805b08..bad7c022b93 100644 --- a/homeassistant/components/azure_devops/translations/id.json +++ b/homeassistant/components/azure_devops/translations/id.json @@ -9,7 +9,7 @@ "invalid_auth": "Autentikasi tidak valid", "project_error": "Tidak bisa mendapatkan info proyek." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 9bae21ec43b..039542f9ed6 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import json import logging import time -from typing import Any, Callable +from typing import Any from azure.eventhub import EventData, EventDataBatch from azure.eventhub.aio import EventHubProducerClient, EventHubSharedKeyCredential diff --git a/homeassistant/components/binary_sensor/translations/el.json b/homeassistant/components/binary_sensor/translations/el.json index f4ed1d55bc2..a3887149bee 100644 --- a/homeassistant/components/binary_sensor/translations/el.json +++ b/homeassistant/components/binary_sensor/translations/el.json @@ -1,4 +1,14 @@ { + "device_automation": { + "condition_type": { + "is_no_update": "{entity_name} \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03c9\u03bc\u03ad\u03bd\u03bf", + "is_update": "{entity_name} \u03ad\u03c7\u03b5\u03b9 \u03bc\u03b9\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + }, + "trigger_type": { + "no_update": "{entity_name} \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03b8\u03b7\u03ba\u03b5", + "update": "{entity_name} \u03ad\u03bb\u03b1\u03b2\u03b5 \u03bc\u03b9\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + } + }, "state": { "_": { "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc\u03c2", @@ -72,6 +82,10 @@ "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" }, + "update": { + "off": "\u03a0\u03bb\u03ae\u03c1\u03c9\u03c2 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03c9\u03bc\u03ad\u03bd\u03bf", + "on": "\u0394\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7" + }, "vibration": { "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" diff --git a/homeassistant/components/binary_sensor/translations/es.json b/homeassistant/components/binary_sensor/translations/es.json index 05fc002ecb0..f72e08d5937 100644 --- a/homeassistant/components/binary_sensor/translations/es.json +++ b/homeassistant/components/binary_sensor/translations/es.json @@ -178,6 +178,10 @@ "off": "No detectado", "on": "Detectado" }, + "update": { + "off": "Actualizado", + "on": "Actualizaci\u00f3n disponible" + }, "vibration": { "off": "No detectado", "on": "Detectado" diff --git a/homeassistant/components/binary_sensor/translations/fr.json b/homeassistant/components/binary_sensor/translations/fr.json index aa0686c0375..74f54a30814 100644 --- a/homeassistant/components/binary_sensor/translations/fr.json +++ b/homeassistant/components/binary_sensor/translations/fr.json @@ -115,12 +115,12 @@ "on": "Connect\u00e9" }, "door": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" }, "garage_door": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" }, "gas": { "off": "Non d\u00e9tect\u00e9", @@ -191,8 +191,8 @@ "on": "D\u00e9tect\u00e9e" }, "window": { - "off": "Ferm\u00e9e", - "on": "Ouverte" + "off": "Ferm\u00e9", + "on": "Ouvert" } }, "title": "Capteur binaire" diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index b0fb2780089..65501c6e698 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -82,7 +82,7 @@ "off": "\u05de\u05e0\u05d5\u05ea\u05e7" }, "presence": { - "off": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "off": "\u05d1\u05d7\u05d5\u05e5", "on": "\u05d1\u05d1\u05d9\u05ea" }, "problem": { diff --git a/homeassistant/components/binary_sensor/translations/id.json b/homeassistant/components/binary_sensor/translations/id.json index ac880aa28fa..54dcb66dd7a 100644 --- a/homeassistant/components/binary_sensor/translations/id.json +++ b/homeassistant/components/binary_sensor/translations/id.json @@ -178,6 +178,9 @@ "off": "Tidak ada", "on": "Terdeteksi" }, + "update": { + "on": "Pembaruan tersedia" + }, "vibration": { "off": "Tidak ada", "on": "Terdeteksi" diff --git a/homeassistant/components/binary_sensor/translations/is.json b/homeassistant/components/binary_sensor/translations/is.json index f53316ebd73..bd1ed9c389a 100644 --- a/homeassistant/components/binary_sensor/translations/is.json +++ b/homeassistant/components/binary_sensor/translations/is.json @@ -45,7 +45,7 @@ "on": "Hreyfing" }, "occupancy": { - "off": "Hreinsa", + "off": "Engin vi\u00f0vera", "on": "Uppg\u00f6tva\u00f0" }, "presence": { diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index 6e6b272d869..7b89d566a63 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -17,7 +17,7 @@ "is_no_problem": "sensor {entity_name} nie wykrywa problemu", "is_no_smoke": "sensor {entity_name} nie wykrywa dymu", "is_no_sound": "sensor {entity_name} nie wykrywa d\u017awi\u0119ku", - "is_no_update": "{entity_name} jest aktualny(-a)", + "is_no_update": "dla {entity_name} nie ma dost\u0119pnej aktualizacji", "is_no_vibration": "sensor {entity_name} nie wykrywa wibracji", "is_not_bat_low": "bateria {entity_name} nie jest roz\u0142adowana", "is_not_cold": "sensor {entity_name} nie wykrywa zimna", @@ -43,7 +43,7 @@ "is_smoke": "sensor {entity_name} wykrywa dym", "is_sound": "sensor {entity_name} wykrywa d\u017awi\u0119k", "is_unsafe": "sensor {entity_name} wykrywa zagro\u017cenie", - "is_update": "{entity_name} ma dost\u0119pn\u0105 aktualizacj\u0119", + "is_update": "dla {entity_name} jest dost\u0119pna aktualizacja", "is_vibration": "sensor {entity_name} wykrywa wibracje" }, "trigger_type": { @@ -63,7 +63,7 @@ "no_problem": "sensor {entity_name} przestanie wykrywa\u0107 problem", "no_smoke": "sensor {entity_name} przestanie wykrywa\u0107 dym", "no_sound": "sensor {entity_name} przestanie wykrywa\u0107 d\u017awi\u0119k", - "no_update": "{entity_name} zosta\u0142 zaktualizowany(-a)", + "no_update": "wykonano aktualizacj\u0119 dla {entity_name}", "no_vibration": "sensor {entity_name} przestanie wykrywa\u0107 wibracje", "not_bat_low": "nast\u0105pi na\u0142adowanie baterii {entity_name}", "not_cold": "sensor {entity_name} przestanie wykrywa\u0107 zimno", @@ -183,8 +183,8 @@ "on": "wykryto" }, "update": { - "off": "Aktualny(-a)", - "on": "Dost\u0119pna aktualizacja" + "off": "brak aktualizacji", + "on": "dost\u0119pna aktualizacja" }, "vibration": { "off": "brak", diff --git a/homeassistant/components/blebox/translations/fr.json b/homeassistant/components/blebox/translations/fr.json index d30d026d177..83983be5be1 100644 --- a/homeassistant/components/blebox/translations/fr.json +++ b/homeassistant/components/blebox/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "address_already_configured": "Un p\u00e9riph\u00e9rique BleBox est d\u00e9j\u00e0 configur\u00e9 \u00e0 {address}.", - "already_configured": "Ce p\u00e9riph\u00e9rique BleBox est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de connecter le p\u00e9riph\u00e9rique BleBox. (V\u00e9rifiez les journaux pour les erreurs.)", - "unknown": "Erreur inconnue lors de la connexion au p\u00e9riph\u00e9rique BleBox. (V\u00e9rifiez les journaux pour les erreurs.)", + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue", "unsupported_version": "L'appareil BleBox a un micrologiciel obsol\u00e8te. Veuillez d'abord le mettre \u00e0 jour." }, "flow_title": "P\u00e9riph\u00e9rique Blebox: {name} ({host)}", diff --git a/homeassistant/components/blebox/translations/hu.json b/homeassistant/components/blebox/translations/hu.json index ce51a8a0967..056402ea13f 100644 --- a/homeassistant/components/blebox/translations/hu.json +++ b/homeassistant/components/blebox/translations/hu.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "unsupported_version": "A BleBox eszk\u00f6z elavult firmware-rel rendelkezik. El\u0151sz\u00f6r friss\u00edtsd." + "unsupported_version": "A BleBox eszk\u00f6z elavult firmware-rel rendelkezik. K\u00e9rem, friss\u00edtse el\u0151bb." }, "flow_title": "{name} ({host})", "step": { diff --git a/homeassistant/components/blebox/translations/id.json b/homeassistant/components/blebox/translations/id.json index 2ef604d1bff..f0bb4d34746 100644 --- a/homeassistant/components/blebox/translations/id.json +++ b/homeassistant/components/blebox/translations/id.json @@ -9,7 +9,7 @@ "unknown": "Kesalahan yang tidak diharapkan", "unsupported_version": "Firmware Perangkat BleBox sudah usang. Tingkatkan terlebih dulu." }, - "flow_title": "Perangkat BleBox: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/blink/translations/fr.json b/homeassistant/components/blink/translations/fr.json index 23bb7fb91dd..bef14d641f7 100644 --- a/homeassistant/components/blink/translations/fr.json +++ b/homeassistant/components/blink/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "P\u00e9riph\u00e9rique d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -20,7 +20,7 @@ "user": { "data": { "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous avec un compte Blink" } diff --git a/homeassistant/components/blink/translations/hu.json b/homeassistant/components/blink/translations/hu.json index 135a2f7ef2e..1822dfbcf50 100644 --- a/homeassistant/components/blink/translations/hu.json +++ b/homeassistant/components/blink/translations/hu.json @@ -14,7 +14,7 @@ "data": { "2fa": "K\u00e9tfaktoros k\u00f3d" }, - "description": "Add meg az e-mail c\u00edmedre k\u00fcld\u00f6tt pint", + "description": "Adja meg az e-mail c\u00edm\u00e9re k\u00fcld\u00f6tt PIN-t", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" }, "user": { diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 797f9bd1512..827d37843d9 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -316,7 +316,7 @@ class DomainBlueprints: raise FileAlreadyExists(self.domain, blueprint_path) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(blueprint.yaml()) + path.write_text(blueprint.yaml(), encoding="utf-8") async def async_add_blueprint( self, blueprint: Blueprint, blueprint_path: str diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index ca1da5987a4..8883f600019 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta import logging -from typing import Any, Callable, Final +from typing import Any, Final import bluetooth # pylint: disable=import-error from bt_proximity import BluetoothRSSI diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index a7fd72fc1a7..225ec5f7f99 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -122,7 +122,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): if has_check_control_messages: cbs_list = [] for message in check_control_messages: - cbs_list.append(message["ccmDescriptionShort"]) + cbs_list.append(message.description_short) result["check_control_messages"] = cbs_list else: result["check_control_messages"] = "OK" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 76d183bf8e8..104a2eb78d9 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -513,6 +513,9 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attr_entity_registry_enabled_default = attribute_info.get( attribute, [None, None, None, True] )[3] + self._attr_icon = self._attribute_info.get( + self._attribute, [None, None, None, None] + )[0] self._attr_device_class = attribute_info.get( attribute, [None, None, None, None] )[1] @@ -570,6 +573,3 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): 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/ca.json b/homeassistant/components/bmw_connected_drive/translations/ca.json index d6bd70064c3..eb12ac6fc3b 100644 --- a/homeassistant/components/bmw_connected_drive/translations/ca.json +++ b/homeassistant/components/bmw_connected_drive/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/bmw_connected_drive/translations/fr.json b/homeassistant/components/bmw_connected_drive/translations/fr.json index 900b352ecb6..aadce398cdc 100644 --- a/homeassistant/components/bmw_connected_drive/translations/fr.json +++ b/homeassistant/components/bmw_connected_drive/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec \u00e0 la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, "step": { diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 818288a5764..4d886c2ee77 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -10,3 +10,9 @@ CONF_BOND_ID: str = "bond_id" HUB = "hub" BPUP_SUBS = "bpup_subs" BPUP_STOP = "bpup_stop" + +SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state" +SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state" +SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state" +SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state" +ATTR_POWER_STATE = "power_state" diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 92ce0b81658..b5d7059b67e 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -5,9 +5,12 @@ import logging import math from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType, Direction +import voluptuous as vol from homeassistant.components.fan import ( + ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, SUPPORT_DIRECTION, @@ -16,6 +19,8 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -24,7 +29,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity from .utils import BondDevice, BondHub @@ -40,6 +45,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() fans: list[Entity] = [ BondFan(hub, device, bpup_subs) @@ -47,6 +53,12 @@ async def async_setup_entry( if DeviceType.is_fan(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_FAN_SPEED_TRACKED_STATE, + {vol.Required(ATTR_SPEED): vol.All(vol.Number(scale=0), vol.Range(0, 100))}, + "async_set_speed_belief", + ) + async_add_entities(fans, True) @@ -128,6 +140,41 @@ class BondFan(BondEntity, FanEntity): self._device.device_id, Action.set_speed(bond_speed) ) + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the believed state to on or off.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_speed_belief(self, speed: int) -> None: + """Set the believed speed for the fan.""" + _LOGGER.debug("async_set_speed_belief called with percentage %s", speed) + if speed == 0: + await self.async_set_power_belief(False) + return + + await self.async_set_power_belief(True) + + bond_speed = math.ceil(percentage_to_ranged_value(self._speed_range, speed)) + _LOGGER.debug( + "async_set_percentage converted percentage %s to bond speed %s", + speed, + bond_speed, + ) + try: + await self._hub.bond.action( + self._device.device_id, Action.set_speed_belief(bond_speed) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_speed_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + async def async_turn_on( self, speed: str | None = None, diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 9fe33e8e99e..82bfa24e44d 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -4,7 +4,9 @@ from __future__ import annotations import logging from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType +import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -14,12 +16,19 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BondHub -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import ( + ATTR_POWER_STATE, + BPUP_SUBS, + DOMAIN, + HUB, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, +) from .entity import BondEntity from .utils import BondDevice @@ -45,6 +54,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform() for service in ENTITY_SERVICES: @@ -92,6 +102,22 @@ async def async_setup_entry( if DeviceType.is_light(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + { + vol.Required(ATTR_BRIGHTNESS): vol.All( + vol.Number(scale=0), vol.Range(0, 255) + ) + }, + "async_set_brightness_belief", + ) + + platform.async_register_entity_service( + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, + {vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)}, + "async_set_power_belief", + ) + async_add_entities( fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights, True, @@ -103,6 +129,34 @@ class BondBaseLight(BondEntity, LightEntity): _attr_supported_features = 0 + async def async_set_brightness_belief(self, brightness: int) -> None: + """Set the belief state of the light.""" + if not self._device.supports_set_brightness(): + raise HomeAssistantError("This device does not support setting brightness") + if brightness == 0: + await self.async_set_power_belief(False) + return + try: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness_belief(round((brightness * 100) / 255)), + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_brightness_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the belief state of the light.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_light_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_light_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + class BondLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" @@ -231,3 +285,31 @@ 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()) + + async def async_set_brightness_belief(self, brightness: int) -> None: + """Set the belief state of the light.""" + if not self._device.supports_set_brightness(): + raise HomeAssistantError("This device does not support setting brightness") + if brightness == 0: + await self.async_set_power_belief(False) + return + try: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness_belief(round((brightness * 100) / 255)), + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_brightness_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set the belief state of the light.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 3995ecf5024..7d1486b2e8f 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,9 +3,9 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.12"], + "requirements": ["bond-api==0.1.13"], "zeroconf": ["_bond._tcp.local."], - "codeowners": ["@prystupa"], + "codeowners": ["@prystupa", "@joshs85"], "quality_scale": "platinum", "iot_class": "local_push" } diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml index 1cb24c5ed71..4ad2b4f9bb3 100644 --- a/homeassistant/components/bond/services.yaml +++ b/homeassistant/components/bond/services.yaml @@ -1,3 +1,97 @@ +# Describes the format for available bond services + +set_fan_speed_tracked_state: + name: Set fan speed tracked state + description: Sets the tracked fan speed for a bond fan + fields: + entity_id: + description: Name(s) of entities to set the tracked fan speed. + example: "fan.living_room_fan" + name: Entity + required: true + selector: + entity: + integration: bond + domain: fan + speed: + required: true + name: Fan Speed + description: Fan Speed as %. + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + mode: slider + +set_switch_power_tracked_state: + name: Set switch power tracked state + description: Sets the tracked power state of a bond switch + fields: + entity_id: + description: Name(s) of entities to set the tracked power state of. + example: "switch.whatever" + name: Entity + required: true + selector: + entity: + integration: bond + domain: switch + power_state: + required: true + name: Power state + description: Power state + example: true + selector: + boolean: + +set_light_power_tracked_state: + name: Set light power tracked state + description: Sets the tracked power state of a bond light + fields: + entity_id: + description: Name(s) of entities to set the tracked power state of. + example: "light.living_room_lights" + name: Entity + required: true + selector: + entity: + integration: bond + domain: light + power_state: + required: true + name: Power state + description: Power state + example: true + selector: + boolean: + +set_light_brightness_tracked_state: + name: Set light brightness tracked state + description: Sets the tracked brightness state of a bond light + fields: + entity_id: + description: Name(s) of entities to set the tracked brightness state of. + example: "light.living_room_lights" + name: Entity + required: true + selector: + entity: + integration: bond + domain: light + brightness: + required: true + name: Brightness + description: Brightness + example: 50 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: slider + start_increasing_brightness: name: Start increasing brightness description: "Start increasing the brightness of the light." @@ -21,3 +115,4 @@ stop: entity: integration: bond domain: light + diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 0bb58946f0f..01c224d8307 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -3,15 +3,25 @@ from __future__ import annotations from typing import Any +from aiohttp.client_exceptions import ClientResponseError from bond_api import Action, BPUPSubscriptions, DeviceType +import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import ( + ATTR_POWER_STATE, + BPUP_SUBS, + DOMAIN, + HUB, + SERVICE_SET_POWER_TRACKED_STATE, +) from .entity import BondEntity from .utils import BondHub @@ -25,6 +35,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() switches: list[Entity] = [ BondSwitch(hub, device, bpup_subs) @@ -32,6 +43,12 @@ async def async_setup_entry( if DeviceType.is_generic(device.type) ] + platform.async_register_entity_service( + SERVICE_SET_POWER_TRACKED_STATE, + {vol.Required(ATTR_POWER_STATE): cv.boolean}, + "async_set_power_belief", + ) + async_add_entities(switches, True) @@ -48,3 +65,14 @@ class BondSwitch(BondEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._hub.bond.action(self._device.device_id, Action.turn_off()) + + async def async_set_power_belief(self, power_state: bool) -> None: + """Set switch power belief.""" + try: + await self._hub.bond.action( + self._device.device_id, Action.set_power_state_belief(power_state) + ) + except ClientResponseError as ex: + raise HomeAssistantError( + f"The bond API returned an error calling set_power_state_belief for {self.entity_id}. Code: {ex.code} Message: {ex.message}" + ) from ex diff --git a/homeassistant/components/bond/translations/es.json b/homeassistant/components/bond/translations/es.json index d9918238515..33d3dbb4408 100644 --- a/homeassistant/components/bond/translations/es.json +++ b/homeassistant/components/bond/translations/es.json @@ -9,13 +9,13 @@ "old_firmware": "Firmware antiguo no compatible en el dispositivo Bond - actual\u00edzalo antes de continuar", "unknown": "Error inesperado" }, - "flow_title": "Bond: {bond_id} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { "access_token": "Token de acceso" }, - "description": "\u00bfQuieres configurar {bond_id}?" + "description": "\u00bfQuieres configurar {name}?" }, "user": { "data": { diff --git a/homeassistant/components/bond/translations/fr.json b/homeassistant/components/bond/translations/fr.json index d9eb14b1a62..7d2450dc9b5 100644 --- a/homeassistant/components/bond/translations/fr.json +++ b/homeassistant/components/bond/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "old_firmware": "Ancien micrologiciel non pris en charge sur l'appareil Bond - veuillez mettre \u00e0 niveau avant de continuer", "unknown": "Erreur inattendue" @@ -19,7 +19,7 @@ }, "user": { "data": { - "access_token": "Token d'acc\u00e8s", + "access_token": "Jeton d'acc\u00e8s", "host": "H\u00f4te" } } diff --git a/homeassistant/components/bond/translations/hu.json b/homeassistant/components/bond/translations/hu.json index 535d3586b93..c1bac971f4b 100644 --- a/homeassistant/components/bond/translations/hu.json +++ b/homeassistant/components/bond/translations/hu.json @@ -15,12 +15,12 @@ "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "Szeretn\u00e9d be\u00e1ll\u00edtani a(z) {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}-t?" }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/bond/translations/id.json b/homeassistant/components/bond/translations/id.json index 56c633cf31c..00a9dbac45d 100644 --- a/homeassistant/components/bond/translations/id.json +++ b/homeassistant/components/bond/translations/id.json @@ -9,7 +9,7 @@ "old_firmware": "Firmware lama yang tidak didukung pada perangkat Bond - tingkatkan versi sebelum melanjutkan", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Bond: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 4415a0ff6ef..416dc6cf304 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -187,16 +187,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="not_bosch_shc") try: - self.info = info = await self._get_info(discovery_info["host"]) + hosts = ( + discovery_info["host"] + if isinstance(discovery_info["host"], list) + else [discovery_info["host"]] + ) + for host in hosts: + if host.startswith("169."): # skip link local address + continue + self.info = await self._get_info(host) + self.host = host + if self.host is None: + return self.async_abort(reason="cannot_connect") except SHCConnectionError: return self.async_abort(reason="cannot_connect") local_name = discovery_info["hostname"][:-1] node_name = local_name[: -len(".local")] - await self.async_set_unique_id(info["unique_id"]) - self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) - self.host = discovery_info["host"] + await self.async_set_unique_id(self.info["unique_id"]) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) self.context["title_placeholders"] = {"name": node_name} return await self.async_step_confirm_discovery() diff --git a/homeassistant/components/bosch_shc/translations/es.json b/homeassistant/components/bosch_shc/translations/es.json index 2b3d4ca7479..df180029c55 100644 --- a/homeassistant/components/bosch_shc/translations/es.json +++ b/homeassistant/components/bosch_shc/translations/es.json @@ -1,8 +1,15 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "pairing_failed": "El emparejamiento ha fallado; compruebe que el Bosch Smart Home Controller est\u00e1 en modo de emparejamiento (el LED parpadea) y que su contrase\u00f1a es correcta.", - "session_error": "Error de sesi\u00f3n: La API devuelve un resultado no correcto." + "session_error": "Error de sesi\u00f3n: La API devuelve un resultado no correcto.", + "unknown": "Error inesperado" }, "flow_title": "Bosch SHC: {name}", "step": { @@ -15,9 +22,13 @@ } }, "reauth_confirm": { - "description": "La integraci\u00f3n bosch_shc necesita volver a autentificar su cuenta" + "description": "La integraci\u00f3n bosch_shc necesita volver a autentificar su cuenta", + "title": "Volver a autenticar la integraci\u00f3n" }, "user": { + "data": { + "host": "Anfitri\u00f3n" + }, "description": "Configura tu Bosch Smart Home Controller para permitir la supervisi\u00f3n y el control con Home Assistant.", "title": "Par\u00e1metros de autenticaci\u00f3n SHC" } diff --git a/homeassistant/components/bosch_shc/translations/fr.json b/homeassistant/components/bosch_shc/translations/fr.json index 38a48b269b4..43eeb04490d 100644 --- a/homeassistant/components/bosch_shc/translations/fr.json +++ b/homeassistant/components/bosch_shc/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "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" @@ -23,7 +23,7 @@ }, "reauth_confirm": { "description": "L'int\u00e9gration bosch_shc doit r\u00e9-authentifier votre compte", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/bosch_shc/translations/hu.json b/homeassistant/components/bosch_shc/translations/hu.json index 8b4ebc6be32..cf0090475b7 100644 --- a/homeassistant/components/bosch_shc/translations/hu.json +++ b/homeassistant/components/bosch_shc/translations/hu.json @@ -14,7 +14,7 @@ "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?" + "description": "K\u00e9rj\u00fck, addig nyomja a Bosch Smart Home Controller el\u00fcls\u0151 gombj\u00e1t, am\u00edg a LED villogni nem kezd.\nK\u00e9szen \u00e1ll {model} @ {host} be\u00e1ll\u00edt\u00e1s\u00e1nak folytat\u00e1s\u00e1ra Home Assistant seg\u00edts\u00e9g\u00e9vel?" }, "credentials": { "data": { @@ -27,9 +27,9 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "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.", + "description": "\u00c1ll\u00edtsa be a Bosch intelligens otthoni vez\u00e9rl\u0151t, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s a vez\u00e9rl\u00e9st Home Assistant seg\u00edts\u00e9g\u00e9vel.", "title": "SHC hiteles\u00edt\u00e9si param\u00e9terek" } } diff --git a/homeassistant/components/braviatv/translations/fr.json b/homeassistant/components/braviatv/translations/fr.json index 68988b1fbfe..d609f1a2fa1 100644 --- a/homeassistant/components/braviatv/translations/fr.json +++ b/homeassistant/components/braviatv/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "no_ip_control": "Le contr\u00f4le IP est d\u00e9sactiv\u00e9 sur votre t\u00e9l\u00e9viseur ou le t\u00e9l\u00e9viseur n'est pas pris en charge." }, "error": { - "cannot_connect": "\u00c9chec de connexion, h\u00f4te ou code PIN non valide.", - "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide.", + "cannot_connect": "\u00c9chec de connexion", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", "unsupported_model": "Votre mod\u00e8le de t\u00e9l\u00e9viseur n'est pas pris en charge." }, "step": { @@ -19,7 +19,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration du t\u00e9l\u00e9viseur Sony Bravia. Si vous rencontrez des probl\u00e8mes de configuration, rendez-vous sur: https://www.home-assistant.io/integrations/braviatv \n\n Assurez-vous que votre t\u00e9l\u00e9viseur est allum\u00e9.", "title": "Sony Bravia TV" diff --git a/homeassistant/components/braviatv/translations/hu.json b/homeassistant/components/braviatv/translations/hu.json index 5f96af8bad7..00e88955c81 100644 --- a/homeassistant/components/braviatv/translations/hu.json +++ b/homeassistant/components/braviatv/translations/hu.json @@ -14,12 +14,12 @@ "data": { "pin": "PIN-k\u00f3d" }, - "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\n Ha a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, l\u00e9pjen a k\u00f6vetkez\u0151re: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.", + "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\nHa a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, l\u00e9pjen a k\u00f6vetkez\u0151re: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.", "title": "Sony Bravia TV enged\u00e9lyez\u00e9se" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Sony Bravia TV integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/braviatv \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a TV be van kapcsolva.", "title": "Sony Bravia TV" diff --git a/homeassistant/components/broadlink/translations/es.json b/homeassistant/components/broadlink/translations/es.json index e7a35c2876f..d0020c55bca 100644 --- a/homeassistant/components/broadlink/translations/es.json +++ b/homeassistant/components/broadlink/translations/es.json @@ -38,7 +38,7 @@ "user": { "data": { "host": "Host", - "timeout": "Se acab\u00f3 el tiempo" + "timeout": "L\u00edmite de tiempo" }, "title": "Conectarse al dispositivo" } diff --git a/homeassistant/components/broadlink/translations/fr.json b/homeassistant/components/broadlink/translations/fr.json index 1d80059fb7a..e39b722d8c9 100644 --- a/homeassistant/components/broadlink/translations/fr.json +++ b/homeassistant/components/broadlink/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Il y a d\u00e9j\u00e0 un processus de configuration en cours pour cet appareil", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", "not_supported": "Dispositif non pris en charge", diff --git a/homeassistant/components/broadlink/translations/hu.json b/homeassistant/components/broadlink/translations/hu.json index 8b8dce984e5..d3a59a03cea 100644 --- a/homeassistant/components/broadlink/translations/hu.json +++ b/homeassistant/components/broadlink/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s 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", @@ -22,22 +22,22 @@ "data": { "name": "N\u00e9v" }, - "title": "V\u00e1lassz egy nevet az eszk\u00f6znek" + "title": "V\u00e1lasszonegy nevet az eszk\u00f6znek" }, "reset": { - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. A hiteles\u00edt\u00e9shez \u00e9s a konfigur\u00e1ci\u00f3 befejez\u00e9s\u00e9hez fel kell oldani az eszk\u00f6z z\u00e1rol\u00e1s\u00e1t. Utas\u00edt\u00e1sok:\n 1. Nyisd meg a Broadlink alkalmaz\u00e1st.\n 2. Kattints az eszk\u00f6zre.\n 3. Kattints a jobb fels\u0151 sarokban tal\u00e1lhat\u00f3 `...` gombra.\n 4. G\u00f6rgess az oldal alj\u00e1ra.\n 5. Kapcsold ki a z\u00e1rol\u00e1s\u00e1t.", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. A hiteles\u00edt\u00e9shez \u00e9s a konfigur\u00e1ci\u00f3 befejez\u00e9s\u00e9hez fel kell oldani az eszk\u00f6z z\u00e1rol\u00e1s\u00e1t. Utas\u00edt\u00e1sok:\n 1. Nyissa meg a Broadlink alkalmaz\u00e1st.\n 2. Kattintson az eszk\u00f6zre.\n 3. Kattintson a jobb fels\u0151 sarokban tal\u00e1lhat\u00f3 `...` gombra.\n 4. G\u00f6rgessen az oldal alj\u00e1ra.\n 5. Kapcsolja ki a z\u00e1rol\u00e1s\u00e1t.", "title": "Az eszk\u00f6z felold\u00e1sa" }, "unlock": { "data": { "unlock": "Igen, csin\u00e1ld." }, - "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet a Home Assistantban. Szeretn\u00e9d feloldani?", + "description": "{name} ({model} a {host} c\u00edmen) z\u00e1rolva van. Ez hiteles\u00edt\u00e9si probl\u00e9m\u00e1khoz vezethet Home Assistantban. Szeretn\u00e9 feloldani?", "title": "Az eszk\u00f6z felold\u00e1sa (opcion\u00e1lis)" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s" }, "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" diff --git a/homeassistant/components/brother/translations/fr.json b/homeassistant/components/brother/translations/fr.json index 5eb00bb4447..d5a53b94622 100644 --- a/homeassistant/components/brother/translations/fr.json +++ b/homeassistant/components/brother/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cette imprimante est d\u00e9j\u00e0 configur\u00e9e.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "unsupported_model": "Ce mod\u00e8le d'imprimante n'est pas pris en charge." }, "error": { @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "type": "Type d'imprimante" }, "description": "Configurez l'int\u00e9gration de l'imprimante Brother. Si vous avez des probl\u00e8mes avec la configuration, allez \u00e0 : https://www.home-assistant.io/integrations/brother" diff --git a/homeassistant/components/brother/translations/hu.json b/homeassistant/components/brother/translations/hu.json index ae950f58f72..f0218dc2647 100644 --- a/homeassistant/components/brother/translations/hu.json +++ b/homeassistant/components/brother/translations/hu.json @@ -9,11 +9,11 @@ "snmp_error": "Az SNMP szerver ki van kapcsolva, vagy a nyomtat\u00f3 nem t\u00e1mogatott.", "wrong_host": "\u00c9rv\u00e9nytelen \u00e1llom\u00e1sn\u00e9v vagy IP-c\u00edm." }, - "flow_title": "Brother nyomtat\u00f3: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "type": "A nyomtat\u00f3 t\u00edpusa" }, "description": "A Brother nyomtat\u00f3 integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha probl\u00e9m\u00e1id vannak a konfigur\u00e1ci\u00f3val, l\u00e1togass el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/brother" @@ -22,7 +22,7 @@ "data": { "type": "A nyomtat\u00f3 t\u00edpusa" }, - "description": "Hozz\u00e1 akarja adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: {serial_number} `, a Home Assistant-hoz?", + "description": "Hozz\u00e1 szeretn\u00e9 adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: `{serial_number}`, Home Assistanthoz?", "title": "Felfedezett Brother nyomtat\u00f3" } } diff --git a/homeassistant/components/brother/translations/id.json b/homeassistant/components/brother/translations/id.json index 5e0b562017c..ed02999710e 100644 --- a/homeassistant/components/brother/translations/id.json +++ b/homeassistant/components/brother/translations/id.json @@ -9,7 +9,7 @@ "snmp_error": "Server SNMP dimatikan atau printer tidak didukung.", "wrong_host": "Nama host atau alamat IP tidak valid." }, - "flow_title": "Printer Brother: {model} {serial_number}", + "flow_title": "{model} {serial_number}", "step": { "user": { "data": { diff --git a/homeassistant/components/bsblan/translations/fr.json b/homeassistant/components/bsblan/translations/fr.json index 0c54aecdd88..2d1388bed18 100644 --- a/homeassistant/components/bsblan/translations/fr.json +++ b/homeassistant/components/bsblan/translations/fr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "passkey": "Cha\u00eene de cl\u00e9 d'acc\u00e8s", "password": "Mot de passe", "port": "Port", diff --git a/homeassistant/components/bsblan/translations/hu.json b/homeassistant/components/bsblan/translations/hu.json index 51feb8b75d7..60a781cc758 100644 --- a/homeassistant/components/bsblan/translations/hu.json +++ b/homeassistant/components/bsblan/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "passkey": "Jelsz\u00f3 karakterl\u00e1nc", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/bsblan/translations/id.json b/homeassistant/components/bsblan/translations/id.json index 6e8ac0bd4cb..83fdb88aae4 100644 --- a/homeassistant/components/bsblan/translations/id.json +++ b/homeassistant/components/bsblan/translations/id.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "BSB-Lan: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/buienradar/translations/id.json b/homeassistant/components/buienradar/translations/id.json index 194ecb51c12..a4331fced9f 100644 --- a/homeassistant/components/buienradar/translations/id.json +++ b/homeassistant/components/buienradar/translations/id.json @@ -1,8 +1,15 @@ { "config": { + "abort": { + "already_configured": "Lokasi sudah dikonfigurasi" + }, + "error": { + "already_configured": "Lokasi sudah dikonfigurasi" + }, "step": { "user": { "data": { + "latitude": "Lintang", "longitude": "Bujur" } } diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 9724e8e1e70..bfa68fe67e6 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import base64 import collections -from collections.abc import Awaitable, Mapping +from collections.abc import Awaitable, Callable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta @@ -14,7 +14,7 @@ import inspect import logging import os from random import SystemRandom -from typing import Callable, Final, cast, final +from typing import Final, cast, final from aiohttp import web import async_timeout @@ -434,6 +434,7 @@ class Camera(Entity): async def stream_source(self) -> str | None: """Return the source of the stream.""" + # pylint: disable=no-self-use return None def camera_image( diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index 4c3ab704e1f..a8a834b60b3 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,7 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], - "requirements": ["PyTurboJPEG==1.5.2"], + "requirements": ["PyTurboJPEG==1.6.1"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/canary/translations/id.json b/homeassistant/components/canary/translations/id.json index 5f092847b4d..6fdc76feb72 100644 --- a/homeassistant/components/canary/translations/id.json +++ b/homeassistant/components/canary/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Canary: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/cast/translations/fr.json b/homeassistant/components/cast/translations/fr.json index b5274cae453..c07122f820f 100644 --- a/homeassistant/components/cast/translations/fr.json +++ b/homeassistant/components/cast/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { "invalid_known_hosts": "Les h\u00f4tes connus doivent \u00eatre une liste d'h\u00f4tes s\u00e9par\u00e9s par des virgules." @@ -15,7 +15,7 @@ "title": "Google Cast" }, "confirm": { - "description": "Voulez-vous configurer Google Cast?" + "description": "Voulez-vous commencer la configuration ?" } } }, diff --git a/homeassistant/components/cast/translations/hu.json b/homeassistant/components/cast/translations/hu.json index 0f64f8de6fe..a4c8da3242e 100644 --- a/homeassistant/components/cast/translations/hu.json +++ b/homeassistant/components/cast/translations/hu.json @@ -11,11 +11,11 @@ "data": { "known_hosts": "Ismert hosztok" }, - "description": "K\u00e9rj\u00fck, add meg a Google Cast konfigur\u00e1ci\u00f3t.", + "description": "Ismert c\u00edmek - A cast eszk\u00f6z\u00f6k hostneveinek vagy IP-c\u00edmeinek vessz\u0151vel elv\u00e1lasztott list\u00e1ja, akkor haszn\u00e1lja, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik.", "title": "Google Cast konfigur\u00e1ci\u00f3" }, "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, @@ -29,7 +29,7 @@ "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.", + "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni Home Assistanthoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\nCEC 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": { diff --git a/homeassistant/components/cast/translations/id.json b/homeassistant/components/cast/translations/id.json index b2c8d515548..b0a54f52897 100644 --- a/homeassistant/components/cast/translations/id.json +++ b/homeassistant/components/cast/translations/id.json @@ -9,10 +9,10 @@ "step": { "config": { "data": { - "known_hosts": "Daftar opsional host yang diketahui jika penemuan mDNS tidak berfungsi." + "known_hosts": "Host yang dikenal" }, - "description": "Masukkan konfigurasi Google Cast.", - "title": "Google Cast" + "description": "Host yang Dikenal - Daftar nama host atau alamat IP perangkat cast, dipisahkan dengan tanda koma, gunakan jika penemuan mDNS tidak berfungsi.", + "title": "Konfigurasi Google Cast" }, "confirm": { "description": "Ingin memulai penyiapan?" diff --git a/homeassistant/components/cast/translations/nl.json b/homeassistant/components/cast/translations/nl.json index fec645993b9..26dc954ef13 100644 --- a/homeassistant/components/cast/translations/nl.json +++ b/homeassistant/components/cast/translations/nl.json @@ -15,7 +15,7 @@ "title": "Google Cast configuratie" }, "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index c4381b65c49..61c7a0758c7 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -3,10 +3,11 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Optional from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_PORT, DOMAIN @@ -26,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: port = entry.data[CONF_PORT] coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) - await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator @@ -34,7 +34,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=f"{host}:{port}") - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + async def async_finish_startup(_): + await coordinator.async_refresh() + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + if hass.state == CoreState.running: + await async_finish_startup(None) + else: + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, async_finish_startup + ) + ) return True @@ -44,7 +55,7 @@ async def async_unload_entry(hass, entry): return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime]): +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[Optional[datetime]]): """Class to manage fetching Cert Expiry data from single endpoint.""" def __init__(self, hass, host, port): diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index d1b9588f5b1..13336c59771 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Cert Expiry platform.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -25,7 +27,7 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._errors = {} + self._errors: dict[str, str] = {} async def _test_connection(self, user_input=None): """Test connection to the server and try to get the certificate.""" diff --git a/homeassistant/components/cert_expiry/translations/fr.json b/homeassistant/components/cert_expiry/translations/fr.json index 070b5e26cba..ae2a83be849 100644 --- a/homeassistant/components/cert_expiry/translations/fr.json +++ b/homeassistant/components/cert_expiry/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cette combinaison h\u00f4te et port est d\u00e9j\u00e0 configur\u00e9e", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "import_failed": "\u00c9chec de l'importation \u00e0 partir de la configuration" }, "error": { diff --git a/homeassistant/components/cert_expiry/translations/hu.json b/homeassistant/components/cert_expiry/translations/hu.json index de459c324df..26f31465115 100644 --- a/homeassistant/components/cert_expiry/translations/hu.json +++ b/homeassistant/components/cert_expiry/translations/hu.json @@ -6,13 +6,13 @@ }, "error": { "connection_refused": "A kapcsolat megtagadva a gazdag\u00e9phez val\u00f3 csatlakoz\u00e1skor", - "connection_timeout": "T\u00fall\u00e9p\u00e9s, amikor ehhez a gazdag\u00e9phez kapcsol\u00f3dik", - "resolve_failed": "Ez a gazdag\u00e9p nem oldhat\u00f3 fel" + "connection_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s, ehhez a c\u00edmhez kapcsol\u00f3d\u00e1skor", + "resolve_failed": "Ez a c\u00edm nem oldhat\u00f3 fel" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "A tan\u00fas\u00edtv\u00e1ny neve", "port": "Port" }, diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 162fbb01545..3b5bc360d7c 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -1,9 +1,9 @@ """Constants for the ClimaCell integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum -from typing import Callable from pyclimacell.const import ( DAILY, diff --git a/homeassistant/components/climacell/translations/hu.json b/homeassistant/components/climacell/translations/hu.json index 909a5cdf1b5..3454a489455 100644 --- a/homeassistant/components/climacell/translations/hu.json +++ b/homeassistant/components/climacell/translations/hu.json @@ -15,7 +15,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, - "description": "Ha a Sz\u00e9less\u00e9g \u00e9s Hossz\u00fas\u00e1g nincs megadva, akkor a Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban l\u00e9v\u0151 alap\u00e9rtelmezett \u00e9rt\u00e9keket fogjuk haszn\u00e1lni. Minden el\u0151rejelz\u00e9si t\u00edpushoz l\u00e9trej\u00f6n egy entit\u00e1s, de alap\u00e9rtelmez\u00e9s szerint csak az \u00e1ltalad kiv\u00e1lasztottak lesznek enged\u00e9lyezve." + "description": "Ha a Sz\u00e9less\u00e9g \u00e9s Hossz\u00fas\u00e1g nincs megadva, akkor a Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban l\u00e9v\u0151 alap\u00e9rtelmezett \u00e9rt\u00e9keket fogjuk haszn\u00e1lni. Minden el\u0151rejelz\u00e9si t\u00edpushoz l\u00e9trej\u00f6n egy entit\u00e1s, de alap\u00e9rtelmez\u00e9s szerint csak az \u00d6n \u00e1ltal kiv\u00e1lasztottak lesznek enged\u00e9lyezve." } } }, diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 4ff2e8fe477..ce4e08f9fd2 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, @@ -112,7 +115,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 038bc227fcd..f1833899fec 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,4 +1,6 @@ """Component to integrate the Home Assistant cloud.""" +import asyncio + from hass_nabucasa import Cloud import voluptuous as vol @@ -193,13 +195,13 @@ async def async_setup(hass, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + _remote_handle_prefs_updated(cloud) + async def _service_handler(service): """Handle service for cloud.""" if service.service == SERVICE_REMOTE_CONNECT: - await cloud.remote.connect() await prefs.async_update(remote_enabled=True) elif service.service == SERVICE_REMOTE_DISCONNECT: - await cloud.remote.disconnect() await prefs.async_update(remote_enabled=False) hass.helpers.service.async_register_admin_service( @@ -228,9 +230,34 @@ async def async_setup(hass, config): cloud.iot.register_on_connect(_on_connect) - await cloud.start() + await cloud.initialize() await http_api.async_setup(hass) account_link.async_setup(hass) return True + + +@callback +def _remote_handle_prefs_updated(cloud: Cloud) -> None: + """Handle remote preferences updated.""" + cur_pref = cloud.client.prefs.remote_enabled + lock = asyncio.Lock() + + # Sync remote connection with prefs + async def remote_prefs_updated(prefs: CloudPreferences) -> None: + """Update remote status.""" + nonlocal cur_pref + + async with lock: + if prefs.remote_enabled == cur_pref: + return + + cur_pref = prefs.remote_enabled + + if cur_pref: + await cloud.remote.connect() + else: + await cloud.remote.disconnect() + + cloud.client.prefs.async_listen_updates(remote_prefs_updated) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 5ad7ddcffed..5bb0db6d057 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -4,9 +4,10 @@ import logging from typing import Any import aiohttp +from awesomeversion import AwesomeVersion from hass_nabucasa import account_link -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION +from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow, event @@ -16,6 +17,8 @@ DATA_SERVICES = "cloud_account_link_services" CACHE_TIMEOUT = 3600 _LOGGER = logging.getLogger(__name__) +CURRENT_VERSION = AwesomeVersion(HA_VERSION) + @callback def async_setup(hass: HomeAssistant): @@ -30,43 +33,12 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): services = await _get_services(hass) for service in services: - if service["service"] == domain and _is_older(service["min_version"]): + if service["service"] == domain and CURRENT_VERSION >= service["min_version"]: return CloudOAuth2Implementation(hass, domain) return -@callback -def _is_older(version: str) -> bool: - """Test if a version is older than the current HA version.""" - version_parts = version.split(".") - - if len(version_parts) != 3: - return False - - try: - version_parts = [int(val) for val in version_parts] - except ValueError: - return False - - patch_number_str = "" - - for char in PATCH_VERSION: - if char.isnumeric(): - patch_number_str += char - else: - break - - try: - patch_number = int(patch_number_str) - except ValueError: - patch_number = 0 - - cur_version_parts = [MAJOR_VERSION, MINOR_VERSION, patch_number] - - return version_parts <= cur_version_parts - - async def _get_services(hass): """Get the available services.""" services = hass.data.get(DATA_SERVICES) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 7394936f355..43ef0ee62da 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -56,12 +56,6 @@ class AlexaConfig(alexa_config.AbstractConfig): self._alexa_sync_unsub = None self._endpoint = None - prefs.async_listen_updates(self._async_prefs_updated) - hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated, - ) - @property def enabled(self): """Return if Alexa is enabled.""" @@ -114,6 +108,12 @@ class AlexaConfig(alexa_config.AbstractConfig): start.async_at_start(self.hass, hass_started) + self._prefs.async_listen_updates(self._async_prefs_updated) + self.hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + def should_expose(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: @@ -171,6 +171,15 @@ class AlexaConfig(alexa_config.AbstractConfig): async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" + if not self._cloud.is_logged_in: + if self.is_reporting_states: + await self.async_disable_proactive_mode() + + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + self._alexa_sync_unsub = None + return + if ALEXA_DOMAIN not in self.hass.config.components and self.enabled: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 93c6fcd9086..54c471e2a83 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -108,8 +108,8 @@ class CloudClient(Interface): return self._google_config - async def logged_in(self) -> None: - """When user logs in.""" + async def cloud_started(self) -> None: + """When cloud is started.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_): @@ -150,7 +150,10 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) - async def cleanups(self) -> None: + async def cloud_stopped(self) -> None: + """When the cloud is stopped.""" + + async def logout_cleanups(self) -> None: """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) @@ -169,6 +172,10 @@ class CloudClient(Interface): if identifier.startswith("remote_"): async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) + async def async_cloud_connect_update(self, connect: bool) -> None: + """Process cloud remote message to client.""" + await self._prefs.async_update(remote_enabled=connect) + async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud alexa message to client.""" cloud_user = await self._prefs.get_cloud_user() diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 65cbe8bb342..f1783771f2f 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -62,7 +62,7 @@ class CloudGoogleConfig(AbstractConfig): @property def should_report_state(self): """Return if states should be proactively reported.""" - return self._cloud.is_logged_in and self._prefs.google_report_state + return self.enabled and self._prefs.google_report_state @property def local_sdk_webhook_id(self): @@ -172,6 +172,13 @@ class CloudGoogleConfig(AbstractConfig): async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" + if not self._cloud.is_logged_in: + if self.is_reporting_state: + self.async_disable_report_state() + if self.is_local_sdk_active: + self.async_disable_local_sdk() + return + if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e9771012379..1f17f46013e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -6,7 +6,7 @@ import logging import aiohttp import async_timeout import attr -from hass_nabucasa import Cloud, auth, thingtalk +from hass_nabucasa import Cloud, auth, cloud_api, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol @@ -24,7 +24,6 @@ from homeassistant.const import ( HTTP_BAD_GATEWAY, HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, - HTTP_OK, HTTP_UNAUTHORIZED, ) @@ -47,30 +46,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -WS_TYPE_STATUS = "cloud/status" -SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_STATUS} -) - - -WS_TYPE_SUBSCRIPTION = "cloud/subscription" -SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_SUBSCRIPTION} -) - - -WS_TYPE_HOOK_CREATE = "cloud/cloudhook/create" -SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_HOOK_CREATE, vol.Required("webhook_id"): str} -) - - -WS_TYPE_HOOK_DELETE = "cloud/cloudhook/delete" -SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_HOOK_DELETE, vol.Required("webhook_id"): str} -) - - _CLOUD_ERRORS = { InvalidTrustedNetworks: ( HTTP_INTERNAL_SERVER_ERROR, @@ -94,17 +69,11 @@ _CLOUD_ERRORS = { async def async_setup(hass): """Initialize the HTTP API.""" async_register_command = hass.components.websocket_api.async_register_command - async_register_command(WS_TYPE_STATUS, websocket_cloud_status, SCHEMA_WS_STATUS) - async_register_command( - WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION - ) + async_register_command(websocket_cloud_status) + async_register_command(websocket_subscription) async_register_command(websocket_update_prefs) - async_register_command( - WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE - ) - async_register_command( - WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE - ) + async_register_command(websocket_hook_create) + async_register_command(websocket_hook_delete) async_register_command(websocket_remote_connect) async_register_command(websocket_remote_disconnect) @@ -311,6 +280,7 @@ class CloudForgotPasswordView(HomeAssistantView): @websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "cloud/status"}) async def websocket_cloud_status(hass, connection, msg): """Handle request for account info. @@ -344,36 +314,19 @@ def _require_cloud_login(handler): @_require_cloud_login @websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT): - response = await cloud.fetch_subscription_info() - - if response.status != HTTP_OK: - connection.send_message( - websocket_api.error_message( - msg["id"], "request_failed", "Failed to request subscription" - ) + try: + with async_timeout.timeout(REQUEST_TIMEOUT): + data = await cloud_api.async_subscription_info(cloud) + except aiohttp.ClientError: + connection.send_error( + msg["id"], "request_failed", "Failed to request subscription" ) - - data = await response.json() - - # Check if a user is subscribed but local info is outdated - # In that case, let's refresh and reconnect - if data.get("provider") and not cloud.is_connected: - _LOGGER.debug("Found disconnected account with valid subscriotion, connecting") - await cloud.auth.async_renew_access_token() - - # Cancel reconnect in progress - if cloud.iot.state != STATE_DISCONNECTED: - await cloud.iot.disconnect() - - hass.async_create_task(cloud.iot.connect()) - - connection.send_message(websocket_api.result_message(msg["id"], data)) + else: + connection.send_result(msg["id"], data) @_require_cloud_login @@ -429,6 +382,12 @@ async def websocket_update_prefs(hass, connection, msg): @_require_cloud_login @websocket_api.async_response @_ws_handle_cloud_errors +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/cloudhook/create", + vol.Required("webhook_id"): str, + } +) async def websocket_hook_create(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -439,6 +398,12 @@ async def websocket_hook_create(hass, connection, msg): @_require_cloud_login @websocket_api.async_response @_ws_handle_cloud_errors +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/cloudhook/delete", + vol.Required("webhook_id"): str, + } +) async def websocket_hook_delete(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -487,7 +452,6 @@ async def websocket_remote_connect(hass, connection, msg): """Handle request for connect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=True) - await cloud.remote.connect() connection.send_result(msg["id"], await _account_data(cloud)) @@ -500,7 +464,6 @@ async def websocket_remote_disconnect(hass, connection, msg): """Handle request for disconnect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=False) - await cloud.remote.disconnect() connection.send_result(msg["id"], await _account_data(cloud)) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 129b9f83819..517aa887a30 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.46.0"], + "requirements": ["hass-nabucasa==0.50.0"], "dependencies": ["http", "webhook"], "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 357575c7bd0..d38a0c272a7 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -7,10 +7,11 @@ "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", + "remote_server": "Remote Server", "alexa_enabled": "Alexa Enabled", "google_enabled": "Google Enabled", "logged_in": "Logged In", "subscription_expiration": "Subscription Expiration" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 6d700c4fb8e..4d8a6eab64c 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -33,6 +33,7 @@ async def system_health_info(hass): data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled + data["remote_server"] = cloud.remote.snitun_server data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, cloud.acme_directory_server diff --git a/homeassistant/components/cloud/translations/ca.json b/homeassistant/components/cloud/translations/ca.json index 4e6a14cd2f0..c5fec79a89d 100644 --- a/homeassistant/components/cloud/translations/ca.json +++ b/homeassistant/components/cloud/translations/ca.json @@ -10,6 +10,7 @@ "relayer_connected": "Encaminador connectat", "remote_connected": "Connexi\u00f3 remota establerta", "remote_enabled": "Connexi\u00f3 remota activada", + "remote_server": "Servidor remot", "subscription_expiration": "Caducitat de la subscripci\u00f3" } } diff --git a/homeassistant/components/cloud/translations/de.json b/homeassistant/components/cloud/translations/de.json index fd5598fa026..0b924c65428 100644 --- a/homeassistant/components/cloud/translations/de.json +++ b/homeassistant/components/cloud/translations/de.json @@ -10,6 +10,7 @@ "relayer_connected": "Relay Verbunden", "remote_connected": "Remote verbunden", "remote_enabled": "Remote aktiviert", + "remote_server": "Remote-Server", "subscription_expiration": "Ablauf des Abonnements" } } diff --git a/homeassistant/components/cloud/translations/el.json b/homeassistant/components/cloud/translations/el.json new file mode 100644 index 00000000000..923f852f036 --- /dev/null +++ b/homeassistant/components/cloud/translations/el.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "remote_server": "\u0391\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03bf\u03c2 \u0394\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae\u03c2" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/en.json b/homeassistant/components/cloud/translations/en.json index 34af1f57cfa..7577a9a51e4 100644 --- a/homeassistant/components/cloud/translations/en.json +++ b/homeassistant/components/cloud/translations/en.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer Connected", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", + "remote_server": "Remote Server", "subscription_expiration": "Subscription Expiration" } } diff --git a/homeassistant/components/cloud/translations/es.json b/homeassistant/components/cloud/translations/es.json index de05ccf527a..f81c71e8292 100644 --- a/homeassistant/components/cloud/translations/es.json +++ b/homeassistant/components/cloud/translations/es.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer conectado", "remote_connected": "Remoto conectado", "remote_enabled": "Remoto habilitado", + "remote_server": "Servidor remoto", "subscription_expiration": "Caducidad de la suscripci\u00f3n" } } diff --git a/homeassistant/components/cloud/translations/et.json b/homeassistant/components/cloud/translations/et.json index 19f8f40b9d5..59c2b8c6e82 100644 --- a/homeassistant/components/cloud/translations/et.json +++ b/homeassistant/components/cloud/translations/et.json @@ -10,6 +10,7 @@ "relayer_connected": "Edastaja on \u00fchendatud", "remote_connected": "Kaug\u00fchendus on loodud", "remote_enabled": "Kaug\u00fchendus on lubatud", + "remote_server": "Kaugserver", "subscription_expiration": "Tellimuse aegumine" } } diff --git a/homeassistant/components/cloud/translations/he.json b/homeassistant/components/cloud/translations/he.json index 9ea65e73c4e..79b550b23e2 100644 --- a/homeassistant/components/cloud/translations/he.json +++ b/homeassistant/components/cloud/translations/he.json @@ -3,7 +3,8 @@ "info": { "alexa_enabled": "Alexa \u05de\u05d5\u05e4\u05e2\u05dc\u05ea", "google_enabled": "Google \u05de\u05d5\u05e4\u05e2\u05dc", - "logged_in": "\u05de\u05d7\u05d5\u05d1\u05e8" + "logged_in": "\u05de\u05d7\u05d5\u05d1\u05e8", + "remote_server": "\u05e9\u05e8\u05ea \u05de\u05e8\u05d5\u05d7\u05e7" } } } \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/hu.json b/homeassistant/components/cloud/translations/hu.json index 8301806831b..3ecfa262ed5 100644 --- a/homeassistant/components/cloud/translations/hu.json +++ b/homeassistant/components/cloud/translations/hu.json @@ -10,6 +10,7 @@ "relayer_connected": "K\u00f6zvet\u00edt\u0151 csatlakoztatva", "remote_connected": "T\u00e1voli csatlakoz\u00e1s", "remote_enabled": "T\u00e1voli hozz\u00e1f\u00e9r\u00e9s enged\u00e9lyezve", + "remote_server": "T\u00e1voli szerver", "subscription_expiration": "El\u0151fizet\u00e9s lej\u00e1rata" } } diff --git a/homeassistant/components/cloud/translations/it.json b/homeassistant/components/cloud/translations/it.json index fbe13abc41e..e867bbacc26 100644 --- a/homeassistant/components/cloud/translations/it.json +++ b/homeassistant/components/cloud/translations/it.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer connesso", "remote_connected": "Connesso in remoto", "remote_enabled": "Remoto abilitato", + "remote_server": "Server remoto", "subscription_expiration": "Scadenza abbonamento" } } diff --git a/homeassistant/components/cloud/translations/nl.json b/homeassistant/components/cloud/translations/nl.json index 7d02a04cd01..eebe8d14be5 100644 --- a/homeassistant/components/cloud/translations/nl.json +++ b/homeassistant/components/cloud/translations/nl.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer verbonden", "remote_connected": "Op afstand verbonden", "remote_enabled": "Op afstand ingeschakeld", + "remote_server": "Externe server", "subscription_expiration": "Afloop abonnement" } } diff --git a/homeassistant/components/cloud/translations/no.json b/homeassistant/components/cloud/translations/no.json index 63779e7fa94..e3ae7a4f766 100644 --- a/homeassistant/components/cloud/translations/no.json +++ b/homeassistant/components/cloud/translations/no.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer tilkoblet", "remote_connected": "Ekstern tilkobling", "remote_enabled": "Ekstern aktivert", + "remote_server": "Ekstern server", "subscription_expiration": "Abonnementets utl\u00f8p" } } diff --git a/homeassistant/components/cloud/translations/pl.json b/homeassistant/components/cloud/translations/pl.json index 30aaeeb77d1..d8fafb78b90 100644 --- a/homeassistant/components/cloud/translations/pl.json +++ b/homeassistant/components/cloud/translations/pl.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer pod\u0142\u0105czony", "remote_connected": "Zdalny dost\u0119p pod\u0142\u0105czony", "remote_enabled": "Zdalny dost\u0119p w\u0142\u0105czony", + "remote_server": "Zdalny serwer", "subscription_expiration": "Wyga\u015bni\u0119cie subskrypcji" } } diff --git a/homeassistant/components/cloud/translations/ru.json b/homeassistant/components/cloud/translations/ru.json index b2d8c55369b..aa3c34ad700 100644 --- a/homeassistant/components/cloud/translations/ru.json +++ b/homeassistant/components/cloud/translations/ru.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d", "remote_connected": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d", "remote_enabled": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d", + "remote_server": "\u0423\u0434\u0430\u043b\u0451\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0432\u0435\u0440", "subscription_expiration": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438" } } diff --git a/homeassistant/components/cloud/translations/th.json b/homeassistant/components/cloud/translations/th.json new file mode 100644 index 00000000000..1171381d568 --- /dev/null +++ b/homeassistant/components/cloud/translations/th.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "remote_server": "\u0e40\u0e0b\u0e34\u0e23\u0e4c\u0e1f\u0e40\u0e27\u0e2d\u0e23\u0e4c\u0e23\u0e30\u0e22\u0e30\u0e44\u0e01\u0e25" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/tr.json b/homeassistant/components/cloud/translations/tr.json index 75d1c768beb..b9b82f2c08c 100644 --- a/homeassistant/components/cloud/translations/tr.json +++ b/homeassistant/components/cloud/translations/tr.json @@ -8,6 +8,7 @@ "relayer_connected": "Yeniden Katman ba\u011fl\u0131", "remote_connected": "Uzaktan Ba\u011fl\u0131", "remote_enabled": "Uzaktan Etkinle\u015ftirildi", + "remote_server": "Sunucuyu Uzaktan Kontrol et", "subscription_expiration": "Aboneli\u011fin Sona Ermesi" } } diff --git a/homeassistant/components/cloud/translations/zh-Hans.json b/homeassistant/components/cloud/translations/zh-Hans.json index eb1daf5e4f3..f4011e3981e 100644 --- a/homeassistant/components/cloud/translations/zh-Hans.json +++ b/homeassistant/components/cloud/translations/zh-Hans.json @@ -10,6 +10,7 @@ "relayer_connected": "\u901a\u8fc7\u4ee3\u7406\u8fde\u63a5", "remote_connected": "\u8fdc\u7a0b\u8fde\u63a5", "remote_enabled": "\u5df2\u542f\u7528\u8fdc\u7a0b\u63a7\u5236", + "remote_server": "\u8fdc\u7a0b\u670d\u52a1\u5668", "subscription_expiration": "\u8ba2\u9605\u5230\u671f\u65f6\u95f4" } } diff --git a/homeassistant/components/cloud/translations/zh-Hant.json b/homeassistant/components/cloud/translations/zh-Hant.json index 8b97fd51a03..619b0dde71c 100644 --- a/homeassistant/components/cloud/translations/zh-Hant.json +++ b/homeassistant/components/cloud/translations/zh-Hant.json @@ -10,6 +10,7 @@ "relayer_connected": "\u4e2d\u7e7c\u5df2\u9023\u7dda", "remote_connected": "\u9060\u7aef\u63a7\u5236\u5df2\u9023\u7dda", "remote_enabled": "\u9060\u7aef\u63a7\u5236\u5df2\u555f\u7528", + "remote_server": "\u9060\u7aef\u4f3a\u670d\u5668", "subscription_expiration": "\u8a02\u95b1\u5230\u671f" } } diff --git a/homeassistant/components/cloudflare/translations/fr.json b/homeassistant/components/cloudflare/translations/fr.json index 677dc8552fb..7add319cf29 100644 --- a/homeassistant/components/cloudflare/translations/fr.json +++ b/homeassistant/components/cloudflare/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "unknown": "Erreur inattendue" }, @@ -14,7 +14,7 @@ "step": { "reauth_confirm": { "data": { - "api_token": "Jeton API", + "api_token": "Jeton d'API", "description": "R\u00e9-authentifiez-vous avec votre compte Cloudflare." } }, diff --git a/homeassistant/components/cloudflare/translations/he.json b/homeassistant/components/cloudflare/translations/he.json index fb0a20a223b..1f53e94240c 100644 --- a/homeassistant/components/cloudflare/translations/he.json +++ b/homeassistant/components/cloudflare/translations/he.json @@ -29,7 +29,7 @@ }, "zone": { "data": { - "zone": "\u05d0\u05b5\u05d6\u05d5\u05b9\u05e8" + "zone": "\u05d0\u05d6\u05d5\u05e8" } } } diff --git a/homeassistant/components/cloudflare/translations/id.json b/homeassistant/components/cloudflare/translations/id.json index c7878017de3..73f0455273c 100644 --- a/homeassistant/components/cloudflare/translations/id.json +++ b/homeassistant/components/cloudflare/translations/id.json @@ -10,7 +10,7 @@ "invalid_auth": "Autentikasi tidak valid", "invalid_zone": "Zona tidak valid" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { "reauth_confirm": { "data": { diff --git a/homeassistant/components/co2signal/translations/es.json b/homeassistant/components/co2signal/translations/es.json index 071ae642c74..921dd22a76a 100644 --- a/homeassistant/components/co2signal/translations/es.json +++ b/homeassistant/components/co2signal/translations/es.json @@ -1,9 +1,22 @@ { "config": { "abort": { - "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API", + "unknown": "Error inesperado" + }, + "error": { + "api_ratelimit": "Excedida tasa l\u00edmite del API", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + }, "country": { "data": { "country_code": "C\u00f3digo del pa\u00eds" @@ -11,6 +24,7 @@ }, "user": { "data": { + "api_key": "Token de acceso", "location": "Obtener datos para" }, "description": "Visite https://co2signal.com/ para solicitar un token." diff --git a/homeassistant/components/co2signal/translations/fr.json b/homeassistant/components/co2signal/translations/fr.json index 4b36bd3bd74..1ed60fd3227 100644 --- a/homeassistant/components/co2signal/translations/fr.json +++ b/homeassistant/components/co2signal/translations/fr.json @@ -24,7 +24,7 @@ }, "user": { "data": { - "api_key": "Token d'acc\u00e8s", + "api_key": "Jeton d'acc\u00e8s", "location": "Obtenir des donn\u00e9es pour" }, "description": "Visitez https://co2signal.com/ pour demander un jeton." diff --git a/homeassistant/components/co2signal/translations/hu.json b/homeassistant/components/co2signal/translations/hu.json index 00bc19e7b49..77dcbddb8f8 100644 --- a/homeassistant/components/co2signal/translations/hu.json +++ b/homeassistant/components/co2signal/translations/hu.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "api_ratelimit": "API maxim\u00e1lis lek\u00e9r\u00e9ssz\u00e1m t\u00fall\u00e9pve", "unknown": "V\u00e1ratlan hiba" }, "error": { - "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "api_ratelimit": "API maxim\u00e1lis lek\u00e9r\u00e9ssz\u00e1m t\u00fall\u00e9pve", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba" }, diff --git a/homeassistant/components/co2signal/translations/id.json b/homeassistant/components/co2signal/translations/id.json new file mode 100644 index 00000000000..76e72a93fd5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/id.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Lintang", + "longitude": "Bujur" + } + }, + "country": { + "data": { + "country_code": "Kode Negara" + } + }, + "user": { + "data": { + "api_key": "Token Akses" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index dc2922d1531..486da82dfcd 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -70,6 +70,7 @@ WALLETS = { "CHZ": "CHZ", "CLF": "CLF", "CLP": "CLP", + "CLV": "CLV", "CNH": "CNH", "CNY": "CNY", "COMP": "COMP", @@ -99,6 +100,7 @@ WALLETS = { "ETH": "ETH", "ETH2": "ETH2", "EUR": "EUR", + "FET": "FET", "FIL": "FIL", "FJD": "FJD", "FKP": "FKP", @@ -206,6 +208,7 @@ WALLETS = { "SCR": "SCR", "SEK": "SEK", "SGD": "SGD", + "SHIB": "SHIB", "SHP": "SHP", "SKL": "SKL", "SLL": "SLL", @@ -311,6 +314,7 @@ RATES = { "CHF": "CHF", "CLF": "CLF", "CLP": "CLP", + "CLV": "CLV", "CNH": "CNH", "CNY": "CNY", "COMP": "COMP", @@ -337,6 +341,7 @@ RATES = { "ETH": "ETH", "ETH2": "ETH2", "EUR": "EUR", + "FET": "FET", "FIL": "FIL", "FJD": "FJD", "FKP": "FKP", @@ -435,6 +440,7 @@ RATES = { "SCR": "SCR", "SEK": "SEK", "SGD": "SGD", + "SHIB": "SHIB", "SHP": "SHP", "SKL": "SKL", "SLL": "SLL", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index d5abb7d66f5..f37af04065e 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -31,7 +31,7 @@ CURRENCY_ICONS = { "USD": "mdi:currency-usd", } -DEFAULT_COIN_ICON = "mdi:currency-usd-circle" +DEFAULT_COIN_ICON = "mdi:cash" ATTRIBUTION = "Data provided by coinbase.com" diff --git a/homeassistant/components/coinbase/translations/fr.json b/homeassistant/components/coinbase/translations/fr.json index e0ec1ae200d..101411edabe 100644 --- a/homeassistant/components/coinbase/translations/fr.json +++ b/homeassistant/components/coinbase/translations/fr.json @@ -5,13 +5,13 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "api_key": "cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "api_token": "API secr\u00e8te", "currencies": "Devises du solde du compte", "exchange_rates": "Taux d'\u00e9change" diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 2a132837388..22e39e373e4 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -90,7 +90,6 @@ class ComfoConnectBridge: def __init__(self, hass, bridge, name, token, friendly_name, pin): """Initialize the ComfoConnect bridge.""" - self.data = {} self.name = name self.hass = hass self.unique_id = bridge.uuid.hex() diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 53bc242ba2f..0cd1738a94a 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import math +from typing import Any from pycomfoconnect import ( CMD_FAN_MODE_AWAY, @@ -38,18 +39,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ComfoConnect fan platform.""" ccb = hass.data[DOMAIN] - add_entities([ComfoConnectFan(ccb.name, ccb)], True) + add_entities([ComfoConnectFan(ccb)], True) class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" - def __init__(self, name, ccb: ComfoConnectBridge) -> None: + current_speed = None + + def __init__(self, ccb: ComfoConnectBridge) -> None: """Initialize the ComfoConnect fan.""" self._ccb = ccb - self._name = name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register for sensor updates.""" _LOGGER.debug("Registering for fan speed") self.async_on_remove( @@ -68,7 +70,7 @@ class ComfoConnectFan(FanEntity): _LOGGER.debug( "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value ) - self._ccb.data[SENSOR_FAN_SPEED_MODE] = value + self.current_speed = value self.schedule_update_ha_state() @property @@ -84,7 +86,7 @@ class ComfoConnectFan(FanEntity): @property def name(self): """Return the name of the fan.""" - return self._name + return self._ccb.name @property def icon(self): @@ -99,10 +101,9 @@ class ComfoConnectFan(FanEntity): @property def percentage(self) -> int | None: """Return the current speed percentage.""" - speed = self._ccb.data.get(SENSOR_FAN_SPEED_MODE) - if speed is None: + if self.current_speed is None: return None - return ranged_value_to_percentage(SPEED_RANGE, speed) + return ranged_value_to_percentage(SPEED_RANGE, self.current_speed) @property def speed_count(self) -> int: @@ -110,28 +111,30 @@ class ComfoConnectFan(FanEntity): return int_states_in_range(SPEED_RANGE) def turn_on( - self, speed: str = None, percentage=None, preset_mode=None, **kwargs + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs, ) -> None: """Turn on the fan.""" - self.set_percentage(percentage) + if percentage is None: + self.set_percentage(1) # Set fan speed to low + else: + self.set_percentage(percentage) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn off the fan (to away).""" self.set_percentage(0) - def set_percentage(self, percentage: int): + def set_percentage(self, percentage: int) -> None: """Set fan speed percentage.""" _LOGGER.debug("Changing fan speed percentage to %s", percentage) - if percentage is None: - cmd = CMD_FAN_MODE_LOW - elif percentage == 0: + if percentage == 0: cmd = CMD_FAN_MODE_AWAY else: speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) cmd = CMD_MAPPING[speed] self._ccb.comfoconnect.cmd_rmi_request(cmd) - - # Update current mode - self.schedule_update_ha_state() diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index a6a625bab99..14590fc1445 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -1,4 +1,5 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" +from dataclasses import dataclass import logging from pycomfoconnect import ( @@ -26,11 +27,14 @@ from pycomfoconnect import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_ID, CONF_RESOURCES, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -72,187 +76,216 @@ ATTR_SUPPLY_TEMPERATURE = "supply_temperature" _LOGGER = logging.getLogger(__name__) -ATTR_LABEL = "label" -ATTR_MULTIPLIER = "multiplier" -ATTR_UNIT = "unit" -SENSOR_TYPES = { - ATTR_CURRENT_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Inside Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_EXTRACT, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_CURRENT_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Inside Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_EXTRACT, - }, - ATTR_CURRENT_RMOT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Current RMOT", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_CURRENT_RMOT, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_OUTSIDE_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Outside Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_OUTDOOR, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_OUTSIDE_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Outside Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_OUTDOOR, - }, - ATTR_SUPPLY_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Supply Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_SUPPLY, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_SUPPLY_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Supply Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_SUPPLY, - }, - ATTR_SUPPLY_FAN_SPEED: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply Fan Speed", - ATTR_UNIT: "rpm", - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_SPEED, - }, - ATTR_SUPPLY_FAN_DUTY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply Fan Duty", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_DUTY, - }, - ATTR_EXHAUST_FAN_SPEED: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust Fan Speed", - ATTR_UNIT: "rpm", - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_SPEED, - }, - ATTR_EXHAUST_FAN_DUTY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust Fan Duty", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_DUTY, - }, - ATTR_EXHAUST_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: "Exhaust Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_ID: SENSOR_TEMPERATURE_EXHAUST, - ATTR_MULTIPLIER: 0.1, - }, - ATTR_EXHAUST_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: "Exhaust Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: None, - ATTR_ID: SENSOR_HUMIDITY_EXHAUST, - }, - ATTR_AIR_FLOW_SUPPLY: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Supply airflow", - ATTR_UNIT: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, - }, - ATTR_AIR_FLOW_EXHAUST: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Exhaust airflow", - ATTR_UNIT: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ATTR_ICON: "mdi:fan", - ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, - }, - ATTR_BYPASS_STATE: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Bypass State", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:camera-iris", - ATTR_ID: SENSOR_BYPASS_STATE, - }, - ATTR_DAYS_TO_REPLACE_FILTER: { - ATTR_DEVICE_CLASS: None, - ATTR_LABEL: "Days to replace filter", - ATTR_UNIT: TIME_DAYS, - ATTR_ICON: "mdi:calendar", - ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, - }, - ATTR_POWER_CURRENT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LABEL: "Power usage", - ATTR_UNIT: POWER_WATT, - ATTR_ICON: None, - ATTR_ID: SENSOR_POWER_CURRENT, - }, - ATTR_POWER_TOTAL: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LABEL: "Power total", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_ICON: None, - ATTR_ID: SENSOR_POWER_TOTAL, - }, - ATTR_PREHEATER_POWER_CURRENT: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_LABEL: "Preheater power usage", - ATTR_UNIT: POWER_WATT, - ATTR_ICON: None, - ATTR_ID: SENSOR_PREHEATER_POWER_CURRENT, - }, - ATTR_PREHEATER_POWER_TOTAL: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_LABEL: "Preheater power total", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_ICON: None, - ATTR_ID: SENSOR_PREHEATER_POWER_TOTAL, - }, -} +@dataclass +class ComfoconnectRequiredKeysMixin: + """Mixin for required keys.""" + + sensor_id: int + + +@dataclass +class ComfoconnectSensorEntityDescription( + SensorEntityDescription, ComfoconnectRequiredKeysMixin +): + """Describes Comfoconnect sensor entity.""" + + multiplier: float = 1 + + +SENSOR_TYPES = ( + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Inside temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_EXTRACT, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Inside humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_EXTRACT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_CURRENT_RMOT, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Current RMOT", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_CURRENT_RMOT, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_OUTSIDE_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Outside temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_OUTDOOR, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_OUTSIDE_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Outside humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_OUTDOOR, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_SUPPLY, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_SUPPLY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_FAN_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply fan speed", + native_unit_of_measurement="rpm", + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_SPEED, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_SUPPLY_FAN_DUTY, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply fan duty", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_DUTY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_FAN_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust fan speed", + native_unit_of_measurement="rpm", + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_SPEED, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_FAN_DUTY, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust fan duty", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_DUTY, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust temperature", + native_unit_of_measurement=TEMP_CELSIUS, + sensor_id=SENSOR_TEMPERATURE_EXHAUST, + multiplier=0.1, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_EXHAUST_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust humidity", + native_unit_of_measurement=PERCENTAGE, + sensor_id=SENSOR_HUMIDITY_EXHAUST, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_AIR_FLOW_SUPPLY, + state_class=STATE_CLASS_MEASUREMENT, + name="Supply airflow", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + icon="mdi:fan-plus", + sensor_id=SENSOR_FAN_SUPPLY_FLOW, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_AIR_FLOW_EXHAUST, + state_class=STATE_CLASS_MEASUREMENT, + name="Exhaust airflow", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + icon="mdi:fan-minus", + sensor_id=SENSOR_FAN_EXHAUST_FLOW, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_BYPASS_STATE, + state_class=STATE_CLASS_MEASUREMENT, + name="Bypass state", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:camera-iris", + sensor_id=SENSOR_BYPASS_STATE, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_DAYS_TO_REPLACE_FILTER, + name="Days to replace filter", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:calendar", + sensor_id=SENSOR_DAYS_TO_REPLACE_FILTER, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_POWER_CURRENT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Power usage", + native_unit_of_measurement=POWER_WATT, + sensor_id=SENSOR_POWER_CURRENT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_POWER_TOTAL, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + name="Energy total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=SENSOR_POWER_TOTAL, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_PREHEATER_POWER_CURRENT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Preheater power usage", + native_unit_of_measurement=POWER_WATT, + sensor_id=SENSOR_PREHEATER_POWER_CURRENT, + ), + ComfoconnectSensorEntityDescription( + key=ATTR_PREHEATER_POWER_TOTAL, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + name="Preheater energy total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=SENSOR_PREHEATER_POWER_TOTAL, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In([desc.key for desc in SENSOR_TYPES])] ) } ) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ComfoConnect fan platform.""" + """Set up the ComfoConnect sensor platform.""" ccb = hass.data[DOMAIN] - sensors = [] - for resource in config[CONF_RESOURCES]: - sensors.append( - ComfoConnectSensor( - name=f"{ccb.name} {SENSOR_TYPES[resource][ATTR_LABEL]}", - ccb=ccb, - sensor_type=resource, - ) - ) + sensors = [ + ComfoConnectSensor(ccb=ccb, description=description) + for description in SENSOR_TYPES + if description.key in config[CONF_RESOURCES] + ] add_entities(sensors, True) @@ -260,76 +293,47 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ComfoConnectSensor(SensorEntity): """Representation of a ComfoConnect sensor.""" - def __init__(self, name, ccb: ComfoConnectBridge, sensor_type) -> None: + _attr_should_poll = False + entity_description: ComfoconnectSensorEntityDescription + + def __init__( + self, + ccb: ComfoConnectBridge, + description: ComfoconnectSensorEntityDescription, + ) -> None: """Initialize the ComfoConnect sensor.""" self._ccb = ccb - self._sensor_type = sensor_type - self._sensor_id = SENSOR_TYPES[self._sensor_type][ATTR_ID] - self._name = name + self.entity_description = description + self._attr_name = f"{ccb.name} {description.name}" + self._attr_unique_id = f"{ccb.unique_id}-{description.key}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register for sensor updates.""" _LOGGER.debug( - "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id + "Registering for sensor %s (%d)", + self.entity_description.key, + self.entity_description.sensor_id, ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format( + self.entity_description.sensor_id + ), self._handle_update, ) ) await self.hass.async_add_executor_job( - self._ccb.comfoconnect.register_sensor, self._sensor_id + self._ccb.comfoconnect.register_sensor, self.entity_description.sensor_id ) def _handle_update(self, value): """Handle update callbacks.""" _LOGGER.debug( "Handle update for sensor %s (%d): %s", - self._sensor_type, - self._sensor_id, + self.entity_description.key, + self.entity_description.sensor_id, value, ) - self._ccb.data[self._sensor_id] = round( - value * SENSOR_TYPES[self._sensor_type].get(ATTR_MULTIPLIER, 1), 2 - ) + self._attr_native_value = round(value * self.entity_description.multiplier, 2) self.schedule_update_ha_state() - - @property - def native_value(self): - """Return the state of the entity.""" - try: - return self._ccb.data[self._sensor_id] - except KeyError: - return None - - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self._ccb.unique_id}-{self._sensor_type}" - - @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 SENSOR_TYPES[self._sensor_type][ATTR_ICON] - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] - - @property - def device_class(self): - """Return the device_class.""" - return SENSOR_TYPES[self._sensor_type][ATTR_DEVICE_CLASS] diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 3ae79664953..e68541973b1 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.21.1"], + "requirements": ["numpy==1.21.2"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 7d07710a4d0..0815216ec79 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,17 +1,13 @@ """Component to configure Home Assistant via an API.""" import asyncio +from http import HTTPStatus import importlib import os import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CONF_ID, - EVENT_COMPONENT_LOADED, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, -) +from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import ATTR_COMPONENT @@ -125,7 +121,7 @@ class BaseEditConfigView(HomeAssistantView): value = self._get_value(hass, current, config_key) if value is None: - return self.json_message("Resource not found", HTTP_NOT_FOUND) + return self.json_message("Resource not found", HTTPStatus.NOT_FOUND) return self.json(value) @@ -134,12 +130,12 @@ class BaseEditConfigView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message(f"Key malformed: {err}", HTTP_BAD_REQUEST) + return self.json_message(f"Key malformed: {err}", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -151,7 +147,9 @@ class BaseEditConfigView(HomeAssistantView): else: self.data_schema(data) except (vol.Invalid, HomeAssistantError) as err: - return self.json_message(f"Message malformed: {err}", HTTP_BAD_REQUEST) + return self.json_message( + f"Message malformed: {err}", HTTPStatus.BAD_REQUEST + ) path = hass.config.path(self.path) @@ -177,7 +175,7 @@ class BaseEditConfigView(HomeAssistantView): path = hass.config.path(self.path) if value is None: - return self.json_message("Resource not found", HTTP_NOT_FOUND) + return self.json_message("Resource not found", HTTPStatus.BAD_REQUEST) self._delete_value(hass, current, config_key) await hass.async_add_executor_job(_write, path, current) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 7fe5cb0d190..cf243137940 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,6 +1,8 @@ """Http views to control the config manager.""" from __future__ import annotations +from http import HTTPStatus + import aiohttp.web_exceptions import voluptuous as vol @@ -8,7 +10,6 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( @@ -69,7 +70,7 @@ class ConfigManagerEntryResourceView(HomeAssistantView): try: result = await hass.config_entries.async_remove(entry_id) except config_entries.UnknownEntry: - return self.json_message("Invalid entry specified", HTTP_NOT_FOUND) + return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) return self.json(result) @@ -90,9 +91,9 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): try: result = await hass.config_entries.async_reload(entry_id) except config_entries.OperationNotAllowed: - return self.json_message("Entry cannot be reloaded", HTTP_FORBIDDEN) + return self.json_message("Entry cannot be reloaded", HTTPStatus.FORBIDDEN) except config_entries.UnknownEntry: - return self.json_message("Invalid entry specified", HTTP_NOT_FOUND) + return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) return self.json({"require_restart": not result}) @@ -116,6 +117,7 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): async def get(self, request): """Not implemented.""" + # pylint: disable=no-self-use raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) # pylint: disable=arguments-differ diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index b0f6fca4817..f8a3ac0cd9f 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,12 +1,13 @@ """Provide configuration end points for Z-Wave.""" from collections import deque +from http import HTTPStatus import logging from aiohttp.web import Response from homeassistant.components.http import HomeAssistantView from homeassistant.components.zwave import DEVICE_CONFIG_SCHEMA_ENTRY, const -from homeassistant.const import HTTP_ACCEPTED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_OK +from homeassistant.const import HTTP_BAD_REQUEST import homeassistant.core as ha import homeassistant.helpers.config_validation as cv @@ -82,10 +83,12 @@ class ZWaveConfigWriteView(HomeAssistantView): hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) if network is None: - return self.json_message("No Z-Wave network data found", HTTP_NOT_FOUND) + return self.json_message( + "No Z-Wave network data found", HTTPStatus.NOT_FOUND + ) _LOGGER.info("Z-Wave configuration written to file") network.write_config() - return self.json_message("Z-Wave configuration saved to file", HTTP_OK) + return self.json_message("Z-Wave configuration saved to file") class ZWaveNodeValueView(HomeAssistantView): @@ -131,7 +134,7 @@ class ZWaveNodeGroupView(HomeAssistantView): network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) groupdata = node.groups groups = {} for key, value in groupdata.items(): @@ -158,7 +161,7 @@ class ZWaveNodeConfigView(HomeAssistantView): network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) config = {} for value in node.get_values( class_id=const.COMMAND_CLASS_CONFIGURATION @@ -189,7 +192,7 @@ class ZWaveUserCodeView(HomeAssistantView): network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) usercodes = {} if not node.has_command_class(const.COMMAND_CLASS_USER_CODE): return self.json(usercodes) @@ -220,7 +223,7 @@ class ZWaveProtectionView(HomeAssistantView): """Get protection data.""" node = network.nodes.get(nodeid) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) protection_options = {} if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json(protection_options) @@ -247,16 +250,16 @@ class ZWaveProtectionView(HomeAssistantView): selection = protection_data["selection"] value_id = int(protection_data[const.ATTR_VALUE_ID]) if node is None: - return self.json_message("Node not found", HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTPStatus.NOT_FOUND) if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json_message( - "No protection commandclass on this node", HTTP_NOT_FOUND + "No protection commandclass on this node", HTTPStatus.NOT_FOUND ) state = node.set_protection(value_id, selection) if not state: return self.json_message( - "Protection setting did not complete", HTTP_ACCEPTED + "Protection setting did not complete", HTTPStatus.ACCEPTED ) - return self.json_message("Protection setting succsessfully set", HTTP_OK) + return self.json_message("Protection setting successfully set") return await hass.async_add_executor_job(_set_protection) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f8534d99935..4d3297d8c65 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -1,4 +1,5 @@ """Support for functionality to have conversations with Home Assistant.""" +from http import HTTPStatus import logging import re @@ -7,7 +8,6 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR from homeassistant.helpers import config_validation as cv, intent from homeassistant.loader import bind_hass @@ -146,7 +146,7 @@ class ConversationProcessView(http.HomeAssistantView): "message": str(err), }, }, - status_code=HTTP_INTERNAL_SERVER_ERROR, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, ) return self.json(intent_result) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 56cf4aecdea..251058c7edd 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -17,10 +17,12 @@ class AbstractConversationAgent(ABC): async def async_get_onboarding(self): """Get onboard data.""" + # pylint: disable=no-self-use return None async def async_set_onboarding(self, shown): """Set onboard data.""" + # pylint: disable=no-self-use return True @abstractmethod diff --git a/homeassistant/components/coolmaster/translations/he.json b/homeassistant/components/coolmaster/translations/he.json index 5903faf3c72..1fd17b10134 100644 --- a/homeassistant/components/coolmaster/translations/he.json +++ b/homeassistant/components/coolmaster/translations/he.json @@ -7,6 +7,7 @@ "step": { "user": { "data": { + "fan_only": "\u05ea\u05de\u05d9\u05db\u05d4 \u05d1\u05de\u05e6\u05d1 \u05de\u05d0\u05d5\u05d5\u05e8\u05e8 \u05d1\u05dc\u05d1\u05d3", "host": "\u05de\u05d0\u05e8\u05d7" } } diff --git a/homeassistant/components/coolmaster/translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json index d52dba6b4b4..5f6f2eb2824 100644 --- a/homeassistant/components/coolmaster/translations/hu.json +++ b/homeassistant/components/coolmaster/translations/hu.json @@ -12,7 +12,7 @@ "fan_only": "T\u00e1mogaott csak ventil\u00e1tor m\u00f3d(ok)", "heat": "T\u00e1mogatott f\u0171t\u00e9si m\u00f3d(ok)", "heat_cool": "T\u00e1mogatott f\u0171t\u00e9si/h\u0171t\u00e9si m\u00f3d(ok)", - "host": "Hoszt", + "host": "C\u00edm", "off": "Ki lehet kapcsolni" }, "title": "\u00c1ll\u00edtsa be a CoolMasterNet kapcsolat r\u00e9szleteit." diff --git a/homeassistant/components/coronavirus/translations/fr.json b/homeassistant/components/coronavirus/translations/fr.json index 9a9a960cf31..26b2937a8ae 100644 --- a/homeassistant/components/coronavirus/translations/fr.json +++ b/homeassistant/components/coronavirus/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "step": { diff --git a/homeassistant/components/coronavirus/translations/id.json b/homeassistant/components/coronavirus/translations/id.json index e2626d16abb..f6bef10f8c0 100644 --- a/homeassistant/components/coronavirus/translations/id.json +++ b/homeassistant/components/coronavirus/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Layanan sudah dikonfigurasi" + "already_configured": "Layanan sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" }, "step": { "user": { diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index e7048032cba..f4a2f4443d1 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, @@ -147,7 +150,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] in STATE_TRIGGER_TYPES: diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py new file mode 100644 index 00000000000..92b2f4de5ca --- /dev/null +++ b/homeassistant/components/crownstone/__init__.py @@ -0,0 +1,25 @@ +"""Integration for Crownstone.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .entry_manager import CrownstoneEntryManager + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Initiate setup for a Crownstone config entry.""" + manager = CrownstoneEntryManager(hass, entry) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager + + return await manager.async_setup() + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload() + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py new file mode 100644 index 00000000000..7c0ea4fd27d --- /dev/null +++ b/homeassistant/components/crownstone/config_flow.py @@ -0,0 +1,260 @@ +"""Flow handler for Crownstone.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from crownstone_cloud import CrownstoneCloud +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant.components import usb +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowHandler, FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_USB_MANUAL_PATH, + CONF_USB_PATH, + CONF_USB_SPHERE, + CONF_USB_SPHERE_OPTION, + CONF_USE_USB_OPTION, + DOMAIN, + DONT_USE_USB, + MANUAL_PATH, + REFRESH_LIST, +) +from .helpers import list_ports_as_str + +CONFIG_FLOW = "config_flow" +OPTIONS_FLOW = "options_flow" + + +class BaseCrownstoneFlowHandler(FlowHandler): + """Represent the base flow for Crownstone.""" + + cloud: CrownstoneCloud + + def __init__( + self, flow_type: str, create_entry_cb: Callable[..., FlowResult] + ) -> None: + """Set up flow instance.""" + self.flow_type = flow_type + self.create_entry_callback = create_entry_cb + self.usb_path: str | None = None + self.usb_sphere_id: str | None = None + + async def async_step_usb_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Set up a Crownstone USB dongle.""" + list_of_ports = await self.hass.async_add_executor_job( + serial.tools.list_ports.comports + ) + if self.flow_type == CONFIG_FLOW: + ports_as_string = list_ports_as_str(list_of_ports) + else: + ports_as_string = list_ports_as_str(list_of_ports, False) + + if user_input is not None: + selection = user_input[CONF_USB_PATH] + + if selection == DONT_USE_USB: + return self.create_entry_callback() + if selection == MANUAL_PATH: + return await self.async_step_usb_manual_config() + if selection != REFRESH_LIST: + if self.flow_type == OPTIONS_FLOW: + index = ports_as_string.index(selection) + else: + index = ports_as_string.index(selection) - 1 + + selected_port: ListPortInfo = list_of_ports[index] + self.usb_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, selected_port.device + ) + return await self.async_step_usb_sphere_config() + + return self.async_show_form( + step_id="usb_config", + data_schema=vol.Schema( + {vol.Required(CONF_USB_PATH): vol.In(ports_as_string)} + ), + ) + + async def async_step_usb_manual_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manually enter Crownstone USB dongle path.""" + if user_input is None: + return self.async_show_form( + step_id="usb_manual_config", + data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}), + ) + + self.usb_path = user_input[CONF_USB_MANUAL_PATH] + return await self.async_step_usb_sphere_config() + + async def async_step_usb_sphere_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select a Crownstone sphere that the USB operates in.""" + spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} + # no need to select if there's only 1 option + sphere_id: str | None = None + if len(spheres) == 1: + sphere_id = next(iter(spheres.values())) + + if user_input is None and sphere_id is None: + return self.async_show_form( + step_id="usb_sphere_config", + data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(spheres.keys())}), + ) + + if sphere_id: + self.usb_sphere_id = sphere_id + elif user_input: + self.usb_sphere_id = spheres[user_input[CONF_USB_SPHERE]] + + return self.create_entry_callback() + + +class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain=DOMAIN): + """Handle a config flow for Crownstone.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> CrownstoneOptionsFlowHandler: + """Return the Crownstone options.""" + return CrownstoneOptionsFlowHandler(config_entry) + + def __init__(self) -> None: + """Initialize the flow.""" + super().__init__(CONFIG_FLOW, self.async_create_new_entry) + self.login_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + ) + + self.cloud = CrownstoneCloud( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_error: + if auth_error.type == "LOGIN_FAILED": + errors["base"] = "invalid_auth" + elif auth_error.type == "LOGIN_FAILED_EMAIL_NOT_VERIFIED": + errors["base"] = "account_not_verified" + except CrownstoneUnknownError: + errors["base"] = "unknown_error" + + # show form again, with the errors + if errors: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + await self.async_set_unique_id(self.cloud.cloud_data.user_id) + self._abort_if_unique_id_configured() + + self.login_info = user_input + return await self.async_step_usb_config() + + def async_create_new_entry(self) -> FlowResult: + """Create a new entry.""" + return super().async_create_entry( + title=f"Account: {self.login_info[CONF_EMAIL]}", + data={ + CONF_EMAIL: self.login_info[CONF_EMAIL], + CONF_PASSWORD: self.login_info[CONF_PASSWORD], + }, + options={CONF_USB_PATH: self.usb_path, CONF_USB_SPHERE: self.usb_sphere_id}, + ) + + +class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): + """Handle Crownstone options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize Crownstone options.""" + super().__init__(OPTIONS_FLOW, self.async_create_new_entry) + self.entry = config_entry + self.updated_options = config_entry.options.copy() + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Crownstone options.""" + self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud + + spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} + usb_path = self.entry.options.get(CONF_USB_PATH) + usb_sphere = self.entry.options.get(CONF_USB_SPHERE) + + options_schema = vol.Schema( + {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} + ) + if usb_path is not None and len(spheres) > 1: + options_schema = options_schema.extend( + { + vol.Optional( + CONF_USB_SPHERE_OPTION, + default=self.cloud.cloud_data.data[usb_sphere].name, + ): vol.In(spheres.keys()) + } + ) + + if user_input is not None: + if user_input[CONF_USE_USB_OPTION] and usb_path is None: + return await self.async_step_usb_config() + if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: + self.updated_options[CONF_USB_PATH] = None + self.updated_options[CONF_USB_SPHERE] = None + elif ( + CONF_USB_SPHERE_OPTION in user_input + and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere + ): + sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] + self.updated_options[CONF_USB_SPHERE] = sphere_id + + return self.async_create_new_entry() + + return self.async_show_form(step_id="init", data_schema=options_schema) + + def async_create_new_entry(self) -> FlowResult: + """Create a new entry.""" + # these attributes will only change when a usb was configured + if self.usb_path is not None and self.usb_sphere_id is not None: + self.updated_options[CONF_USB_PATH] = self.usb_path + self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id + + return super().async_create_entry(title="", data=self.updated_options) diff --git a/homeassistant/components/crownstone/const.py b/homeassistant/components/crownstone/const.py new file mode 100644 index 00000000000..21a14b99e86 --- /dev/null +++ b/homeassistant/components/crownstone/const.py @@ -0,0 +1,42 @@ +"""Constants for the crownstone integration.""" +from __future__ import annotations + +from typing import Final + +# Platforms +DOMAIN: Final = "crownstone" +PLATFORMS: Final[list[str]] = ["light"] + +# Listeners +SSE_LISTENERS: Final = "sse_listeners" +UART_LISTENERS: Final = "uart_listeners" + +# Unique ID suffixes +CROWNSTONE_SUFFIX: Final = "crownstone" + +# Signals (within integration) +SIG_CROWNSTONE_STATE_UPDATE: Final = "crownstone.crownstone_state_update" +SIG_CROWNSTONE_UPDATE: Final = "crownstone.crownstone_update" +SIG_UART_STATE_CHANGE: Final = "crownstone.uart_state_change" + +# Config flow +CONF_USB_PATH: Final = "usb_path" +CONF_USB_MANUAL_PATH: Final = "usb_manual_path" +CONF_USB_SPHERE: Final = "usb_sphere" +# Options flow +CONF_USE_USB_OPTION: Final = "use_usb_option" +CONF_USB_SPHERE_OPTION: Final = "usb_sphere_option" +# USB config list entries +DONT_USE_USB: Final = "Don't use USB" +REFRESH_LIST: Final = "Refresh list" +MANUAL_PATH: Final = "Enter manually" + +# Crownstone entity +CROWNSTONE_INCLUDE_TYPES: Final[dict[str, str]] = { + "PLUG": "Plug", + "BUILTIN": "Built-in", + "BUILTIN_ONE": "Built-in One", +} + +# Crownstone USB Dongle +CROWNSTONE_USB: Final = "CROWNSTONE_USB" diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/devices.py new file mode 100644 index 00000000000..91af18ab15e --- /dev/null +++ b/homeassistant/components/crownstone/devices.py @@ -0,0 +1,45 @@ +"""Base classes for Crownstone devices.""" +from __future__ import annotations + +from crownstone_cloud.cloud_models.crownstones import Crownstone + +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN + + +class CrownstoneBaseEntity(Entity): + """Base entity class for Crownstone devices.""" + + _attr_should_poll = False + + def __init__(self, device: Crownstone) -> None: + """Initialize the device.""" + self.device = device + + @property + def cloud_id(self) -> str: + """ + Return the unique identifier for this device. + + Used as device ID and to generate unique entity ID's. + """ + return str(self.device.cloud_id) + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self.cloud_id)}, + ATTR_NAME: self.device.name, + ATTR_MANUFACTURER: "Crownstone", + ATTR_MODEL: CROWNSTONE_INCLUDE_TYPES[self.device.type], + ATTR_SW_VERSION: self.device.sw_version, + } diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py new file mode 100644 index 00000000000..b1963462adc --- /dev/null +++ b/homeassistant/components/crownstone/entry_manager.py @@ -0,0 +1,189 @@ +"""Manager to set up IO with Crownstone devices for a config entry.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from crownstone_cloud import CrownstoneCloud +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +from crownstone_sse import CrownstoneSSEAsync +from crownstone_uart import CrownstoneUart, UartEventBus +from crownstone_uart.Exceptions import UartException + +from homeassistant.components import persistent_notification +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_USB_PATH, + CONF_USB_SPHERE, + DOMAIN, + PLATFORMS, + SSE_LISTENERS, + UART_LISTENERS, +) +from .helpers import get_port +from .listeners import setup_sse_listeners, setup_uart_listeners + +_LOGGER = logging.getLogger(__name__) + + +class CrownstoneEntryManager: + """Manage a Crownstone config entry.""" + + uart: CrownstoneUart | None = None + cloud: CrownstoneCloud + sse: CrownstoneSSEAsync + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the hub.""" + self.hass = hass + self.config_entry = config_entry + self.listeners: dict[str, Any] = {} + self.usb_sphere_id: str | None = None + + async def async_setup(self) -> bool: + """ + Set up a Crownstone config entry. + + Returns True if the setup was successful. + """ + email = self.config_entry.data[CONF_EMAIL] + password = self.config_entry.data[CONF_PASSWORD] + + self.cloud = CrownstoneCloud( + email=email, + password=password, + clientsession=aiohttp_client.async_get_clientsession(self.hass), + ) + # Login & sync all user data + try: + await self.cloud.async_initialize() + except CrownstoneAuthenticationError as auth_err: + _LOGGER.error( + "Auth error during login with type: %s and message: %s", + auth_err.type, + auth_err.message, + ) + return False + except CrownstoneUnknownError as unknown_err: + _LOGGER.error("Unknown error during login") + raise ConfigEntryNotReady from unknown_err + + # A new clientsession is created because the default one does not cleanup on unload + self.sse = CrownstoneSSEAsync( + email=email, + password=password, + access_token=self.cloud.access_token, + websession=aiohttp_client.async_create_clientsession(self.hass), + ) + # Listen for events in the background, without task tracking + asyncio.create_task(self.async_process_events(self.sse)) + setup_sse_listeners(self) + + # Set up a Crownstone USB only if path exists + if self.config_entry.options[CONF_USB_PATH] is not None: + await self.async_setup_usb() + + # Save the sphere where the USB is located + # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple + self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] + + self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) + + # HA specific listeners + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(_async_update_listener) + ) + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown) + ) + + return True + + async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None: + """Asynchronous iteration of Crownstone SSE events.""" + async with sse_client as client: + async for event in client: + if event is not None: + async_dispatcher_send(self.hass, f"{DOMAIN}_{event.type}", event) + + async def async_setup_usb(self) -> None: + """Attempt setup of a Crownstone usb dongle.""" + # Trace by-id symlink back to the serial port + serial_port = await self.hass.async_add_executor_job( + get_port, self.config_entry.options[CONF_USB_PATH] + ) + if serial_port is None: + return + + self.uart = CrownstoneUart() + # UartException is raised when serial controller fails to open + try: + await self.uart.initialize_usb(serial_port) + except UartException: + self.uart = None + # Set entry options for usb to null + updated_options = self.config_entry.options.copy() + updated_options[CONF_USB_PATH] = None + updated_options[CONF_USB_SPHERE] = None + # Ensure that the user can configure an USB again from options + self.hass.config_entries.async_update_entry( + self.config_entry, options=updated_options + ) + # Show notification to ensure the user knows the cloud is now used + persistent_notification.async_create( + self.hass, + f"Setup of Crownstone USB dongle was unsuccessful on port {serial_port}.\n \ + Crownstone Cloud will be used to switch Crownstones.\n \ + Please check if your port is correct and set up the USB again from integration options.", + "Crownstone", + "crownstone_usb_dongle_setup", + ) + return + + setup_uart_listeners(self) + + async def async_unload(self) -> bool: + """Unload the current config entry.""" + # Authentication failed + if self.cloud.cloud_data is None: + return True + + self.sse.close_client() + for sse_unsub in self.listeners[SSE_LISTENERS]: + sse_unsub() + + if self.uart: + self.uart.stop() + for subscription_id in self.listeners[UART_LISTENERS]: + UartEventBus.unsubscribe(subscription_id) + + unload_ok = await self.hass.config_entries.async_unload_platforms( + self.config_entry, PLATFORMS + ) + + if unload_ok: + self.hass.data[DOMAIN].pop(self.config_entry.entry_id) + + return unload_ok + + @callback + def on_shutdown(self, _: Event) -> None: + """Close all IO connections.""" + self.sse.close_client() + if self.uart: + self.uart.stop() + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py new file mode 100644 index 00000000000..58b4dcdba47 --- /dev/null +++ b/homeassistant/components/crownstone/helpers.py @@ -0,0 +1,59 @@ +"""Helper functions for the Crownstone integration.""" +from __future__ import annotations + +import os + +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant.components import usb + +from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST + + +def list_ports_as_str( + serial_ports: list[ListPortInfo], no_usb_option: bool = True +) -> list[str]: + """ + Represent currently available serial ports as string. + + Adds option to not use usb on top of the list, + option to use manual path or refresh list at the end. + """ + ports_as_string: list[str] = [] + + if no_usb_option: + ports_as_string.append(DONT_USE_USB) + + for port in serial_ports: + ports_as_string.append( + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None, + f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None, + ) + ) + ports_as_string.append(MANUAL_PATH) + ports_as_string.append(REFRESH_LIST) + + return ports_as_string + + +def get_port(dev_path: str) -> str | None: + """Get the port that the by-id link points to.""" + # not a by-id link, but just given path + by_id = "/dev/serial/by-id" + if by_id not in dev_path: + return dev_path + + try: + return f"/dev/{os.path.basename(os.readlink(dev_path))}" + except FileNotFoundError: + return None + + +def map_from_to(val: int, in_min: int, in_max: int, out_min: int, out_max: int) -> int: + """Map a value from a range to another.""" + return int((val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py new file mode 100644 index 00000000000..ff647b2fc84 --- /dev/null +++ b/homeassistant/components/crownstone/light.py @@ -0,0 +1,168 @@ +"""Support for Crownstone devices.""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, Any + +from crownstone_cloud.cloud_models.crownstones import Crownstone +from crownstone_cloud.const import DIMMING_ABILITY +from crownstone_cloud.exceptions import CrownstoneAbilityError +from crownstone_uart import CrownstoneUart + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CROWNSTONE_INCLUDE_TYPES, + CROWNSTONE_SUFFIX, + DOMAIN, + SIG_CROWNSTONE_STATE_UPDATE, + SIG_UART_STATE_CHANGE, +) +from .devices import CrownstoneBaseEntity +from .helpers import map_from_to + +if TYPE_CHECKING: + from .entry_manager import CrownstoneEntryManager + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up crownstones from a config entry.""" + manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[CrownstoneEntity] = [] + + # Add Crownstone entities that support switching/dimming + for sphere in manager.cloud.cloud_data: + for crownstone in sphere.crownstones: + if crownstone.type in CROWNSTONE_INCLUDE_TYPES: + # Crownstone can communicate with Crownstone USB + if manager.uart and sphere.cloud_id == manager.usb_sphere_id: + entities.append(CrownstoneEntity(crownstone, manager.uart)) + # Crownstone can't communicate with Crownstone USB + else: + entities.append(CrownstoneEntity(crownstone)) + + async_add_entities(entities) + + +def crownstone_state_to_hass(value: int) -> int: + """Crownstone 0..100 to hass 0..255.""" + return map_from_to(value, 0, 100, 0, 255) + + +def hass_to_crownstone_state(value: int) -> int: + """Hass 0..255 to Crownstone 0..100.""" + return map_from_to(value, 0, 255, 0, 100) + + +class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): + """ + Representation of a crownstone. + + Light platform is used to support dimming. + """ + + _attr_icon = "mdi:power-socket-de" + + def __init__( + self, crownstone_data: Crownstone, usb: CrownstoneUart | None = None + ) -> None: + """Initialize the crownstone.""" + super().__init__(crownstone_data) + self.usb = usb + # Entity class attributes + self._attr_name = str(self.device.name) + self._attr_unique_id = f"{self.cloud_id}-{CROWNSTONE_SUFFIX}" + + @property + def brightness(self) -> int | None: + """Return the brightness if dimming enabled.""" + return crownstone_state_to_hass(self.device.state) + + @property + def is_on(self) -> bool: + """Return if the device is on.""" + return crownstone_state_to_hass(self.device.state) > 0 + + @property + def supported_features(self) -> int: + """Return the supported features of this Crownstone.""" + if self.device.abilities.get(DIMMING_ABILITY).is_enabled: + return SUPPORT_BRIGHTNESS + return 0 + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + # new state received + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIG_CROWNSTONE_STATE_UPDATE, self.async_write_ha_state + ) + ) + # updates state attributes when usb connects/disconnects + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIG_UART_STATE_CHANGE, self.async_write_ha_state + ) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on this light via dongle or cloud.""" + if ATTR_BRIGHTNESS in kwargs: + if self.usb is not None and self.usb.is_ready(): + await self.hass.async_add_executor_job( + partial( + self.usb.dim_crownstone, + self.device.unique_id, + hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]), + ) + ) + else: + try: + await self.device.async_set_brightness( + hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) + ) + except CrownstoneAbilityError as ability_error: + raise HomeAssistantError(ability_error) from ability_error + + # assume brightness is set on device + self.device.state = hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]) + self.async_write_ha_state() + + elif self.usb is not None and self.usb.is_ready(): + await self.hass.async_add_executor_job( + partial(self.usb.switch_crownstone, self.device.unique_id, on=True) + ) + self.device.state = 100 + self.async_write_ha_state() + + else: + await self.device.async_turn_on() + self.device.state = 100 + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off this device via dongle or cloud.""" + if self.usb is not None and self.usb.is_ready(): + await self.hass.async_add_executor_job( + partial(self.usb.switch_crownstone, self.device.unique_id, on=False) + ) + + else: + await self.device.async_turn_off() + + self.device.state = 0 + self.async_write_ha_state() diff --git a/homeassistant/components/crownstone/listeners.py b/homeassistant/components/crownstone/listeners.py new file mode 100644 index 00000000000..63891545cab --- /dev/null +++ b/homeassistant/components/crownstone/listeners.py @@ -0,0 +1,154 @@ +""" +Listeners for updating data in the Crownstone integration. + +For data updates, Cloud Push is used in form of an SSE server that sends out events. +For fast device switching Local Push is used in form of a USB dongle that hooks into a BLE mesh. +""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, cast + +from crownstone_cloud.exceptions import CrownstoneNotFoundError +from crownstone_core.packets.serviceDataParsers.containers.AdvExternalCrownstoneState import ( + AdvExternalCrownstoneState, +) +from crownstone_core.packets.serviceDataParsers.containers.elements.AdvTypes import ( + AdvType, +) +from crownstone_core.protocol.SwitchState import SwitchState +from crownstone_sse.const import ( + EVENT_ABILITY_CHANGE, + EVENT_ABILITY_CHANGE_DIMMING, + EVENT_SWITCH_STATE_UPDATE, +) +from crownstone_sse.events import AbilityChangeEvent, SwitchStateUpdateEvent +from crownstone_uart import UartEventBus, UartTopics +from crownstone_uart.topics.SystemTopics import SystemTopics + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) + +from .const import ( + DOMAIN, + SIG_CROWNSTONE_STATE_UPDATE, + SIG_UART_STATE_CHANGE, + SSE_LISTENERS, + UART_LISTENERS, +) + +if TYPE_CHECKING: + from .entry_manager import CrownstoneEntryManager + + +@callback +def async_update_crwn_state_sse( + manager: CrownstoneEntryManager, switch_event: SwitchStateUpdateEvent +) -> None: + """Update the state of a Crownstone when switched externally.""" + try: + updated_crownstone = manager.cloud.get_crownstone_by_id(switch_event.cloud_id) + except CrownstoneNotFoundError: + return + + # only update on change. + if updated_crownstone.state != switch_event.switch_state: + updated_crownstone.state = switch_event.switch_state + async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +@callback +def async_update_crwn_ability( + manager: CrownstoneEntryManager, ability_event: AbilityChangeEvent +) -> None: + """Update the ability information of a Crownstone.""" + try: + updated_crownstone = manager.cloud.get_crownstone_by_id(ability_event.cloud_id) + except CrownstoneNotFoundError: + return + + ability_type = ability_event.ability_type + ability_enabled = ability_event.ability_enabled + # only update on a change in state + if updated_crownstone.abilities[ability_type].is_enabled == ability_enabled: + return + + # write the change to the crownstone entity. + updated_crownstone.abilities[ability_type].is_enabled = ability_enabled + + if ability_event.sub_type == EVENT_ABILITY_CHANGE_DIMMING: + # reload the config entry because dimming is part of supported features + manager.hass.async_create_task( + manager.hass.config_entries.async_reload(manager.config_entry.entry_id) + ) + else: + async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +def update_uart_state(manager: CrownstoneEntryManager, _: bool | None) -> None: + """Update the uart ready state for entities that use USB.""" + # update availability of power usage entities. + dispatcher_send(manager.hass, SIG_UART_STATE_CHANGE) + + +def update_crwn_state_uart( + manager: CrownstoneEntryManager, data: AdvExternalCrownstoneState +) -> None: + """Update the state of a Crownstone when switched externally.""" + if data.type != AdvType.EXTERNAL_STATE: + return + try: + updated_crownstone = manager.cloud.get_crownstone_by_uid( + data.crownstoneId, manager.usb_sphere_id + ) + except CrownstoneNotFoundError: + return + + if data.switchState is None: + return + # update on change + updated_state = cast(SwitchState, data.switchState) + if updated_crownstone.state != updated_state.intensity: + updated_crownstone.state = updated_state.intensity + + dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE) + + +def setup_sse_listeners(manager: CrownstoneEntryManager) -> None: + """Set up SSE listeners.""" + # save unsub function for when entry removed + manager.listeners[SSE_LISTENERS] = [ + async_dispatcher_connect( + manager.hass, + f"{DOMAIN}_{EVENT_SWITCH_STATE_UPDATE}", + partial(async_update_crwn_state_sse, manager), + ), + async_dispatcher_connect( + manager.hass, + f"{DOMAIN}_{EVENT_ABILITY_CHANGE}", + partial(async_update_crwn_ability, manager), + ), + ] + + +def setup_uart_listeners(manager: CrownstoneEntryManager) -> None: + """Set up UART listeners.""" + # save subscription id to unsub + manager.listeners[UART_LISTENERS] = [ + UartEventBus.subscribe( + SystemTopics.connectionEstablished, + partial(update_uart_state, manager), + ), + UartEventBus.subscribe( + SystemTopics.connectionClosed, + partial(update_uart_state, manager), + ), + UartEventBus.subscribe( + UartTopics.newDataAvailable, + partial(update_crwn_state_uart, manager), + ), + ] diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json new file mode 100644 index 00000000000..4615d0b0329 --- /dev/null +++ b/homeassistant/components/crownstone/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "crownstone", + "name": "Crownstone", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/crownstone", + "requirements": [ + "crownstone-cloud==1.4.8", + "crownstone-sse==2.0.2", + "crownstone-uart==2.1.0", + "pyserial==3.5" + ], + "codeowners": ["@Crownstone", "@RicArch97"], + "after_dependencies": ["usb"], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/crownstone/strings.json b/homeassistant/components/crownstone/strings.json new file mode 100644 index 00000000000..25c9fd10293 --- /dev/null +++ b/homeassistant/components/crownstone/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "usb_setup_complete": "Crownstone USB setup complete.", + "usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful." + }, + "error": { + "account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.", + "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%]" + }, + "title": "Crownstone account" + }, + "usb_config": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle configuration", + "description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle manual path", + "description": "Manually enter the path of a Crownstone USB dongle." + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "title": "Crownstone USB Sphere", + "description": "Select a Crownstone Sphere where the USB is located." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "use_usb_option": "Use a Crownstone USB dongle for local data transmission", + "usb_sphere_option": "Crownstone Sphere where the USB is located" + } + }, + "usb_config": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle configuration", + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Crownstone USB dongle manual path", + "description": "Manually enter the path of a Crownstone USB dongle." + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "title": "Crownstone USB Sphere", + "description": "Select a Crownstone Sphere where the USB is located." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ca.json b/homeassistant/components/crownstone/translations/ca.json new file mode 100644 index 00000000000..9de845d87c6 --- /dev/null +++ b/homeassistant/components/crownstone/translations/ca.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "usb_setup_complete": "S'ha completat la configuraci\u00f3 USB de Crownstone.", + "usb_setup_unsuccessful": "La configuraci\u00f3 USB de Crownstone ha fallat." + }, + "error": { + "account_not_verified": "Compte no verificat. Activa el teu compte mitjan\u00e7ant el correu d'activaci\u00f3 de Crownstone.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone o selecciona 'No utilitzar USB' si no vols configurar l'adaptador USB.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "title": "Compte de Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Esfera Crownstone on es troba l'USB.", + "use_usb_option": "Utilitza un adaptador USB Crownstone per a la transmissi\u00f3 de dades locals" + } + }, + "usb_config": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Ruta del dispositiu USB" + }, + "description": "Selecciona el port s\u00e8rie de l'adaptador USB Crownstone.\n\nBusca un dispositiu amb VID 10C4 i PID EA60.", + "title": "Configuraci\u00f3 de l'adaptador USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Ruta del dispositiu USB" + }, + "description": "Introdueix manualment la ruta de l'adaptador USB Crownstone.", + "title": "Ruta manual de l'adaptador USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona la esfera Crownstone on es troba l'USB.", + "title": "Esfera Crownstone USB" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/cs.json b/homeassistant/components/crownstone/translations/cs.json new file mode 100644 index 00000000000..a7aaa1746f9 --- /dev/null +++ b/homeassistant/components/crownstone/translations/cs.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "usb_manual_config": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + }, + "options": { + "step": { + "usb_config_option": { + "data": { + "usb_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Cesta k USB za\u0159\u00edzen\u00ed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/de.json b/homeassistant/components/crownstone/translations/de.json new file mode 100644 index 00000000000..a969d9b2999 --- /dev/null +++ b/homeassistant/components/crownstone/translations/de.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "usb_setup_complete": "Crownstone USB-Einrichtung abgeschlossen.", + "usb_setup_unsuccessful": "Crownstone USB-Einrichtung war nicht erfolgreich." + }, + "error": { + "account_not_verified": "Konto nicht verifiziert. Bitte aktiviere dein Konto \u00fcber die Aktivierungs-E-Mail von Crownstone.", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles aus, oder w\u00e4hle \"Don't use USB\", wenn du keinen USB-Dongle einrichten m\u00f6chtest.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + }, + "title": "Crownstone-Konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, wo sich der USB befindet", + "use_usb_option": "Verwende einen Crownstone USB-Dongle f\u00fcr die lokale Daten\u00fcbertragung" + } + }, + "usb_config": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_config_option": { + "data": { + "usb_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "W\u00e4hle den seriellen Anschluss des Crownstone-USB-Dongles.\n\nSuche nach einem Ger\u00e4t mit VID 10C4 und PID EA60.", + "title": "Crownstone USB-Dongle-Konfiguration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB-Ger\u00e4te-Pfad" + }, + "description": "Gib den Pfad eines Crownstone USB-Dongles manuell ein.", + "title": "Crownstone USB-Dongle manueller Pfad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "W\u00e4hle eine Crownstone Sphere aus, in der sich der USB-Stick befindet.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/en.json b/homeassistant/components/crownstone/translations/en.json new file mode 100644 index 00000000000..d6070c90a0f --- /dev/null +++ b/homeassistant/components/crownstone/translations/en.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "usb_setup_complete": "Crownstone USB setup complete.", + "usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful." + }, + "error": { + "account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Crownstone account" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere where the USB is located", + "use_usb_option": "Use a Crownstone USB dongle for local data transmission" + } + }, + "usb_config": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_config_option": { + "data": { + "usb_path": "USB Device Path" + }, + "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.", + "title": "Crownstone USB dongle configuration" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB Device Path" + }, + "description": "Manually enter the path of a Crownstone USB dongle.", + "title": "Crownstone USB dongle manual path" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Select a Crownstone Sphere where the USB is located.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/es.json b/homeassistant/components/crownstone/translations/es.json new file mode 100644 index 00000000000..f9038fb22b4 --- /dev/null +++ b/homeassistant/components/crownstone/translations/es.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositivo USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositivo USB" + } + }, + "user": { + "data": { + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "Ruta del dispositivo USB" + }, + "description": "Seleccione el puerto serie del dispositivo USB Crownstone.\n\nBusque un dispositivo con VID 10C4 y PID EA60.", + "title": "Configuraci\u00f3n del dispositivo USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Ruta del dispositivo USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Ruta del dispositivo USB" + }, + "description": "Introduzca manualmente la ruta de un dispositivo USB Crownstone.", + "title": "Ruta manual del dispositivo USB Crownstone" + }, + "usb_manual_config_option": { + "title": "Ruta manual del dispositivo USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona una Esfera Crownstone donde se encuentra el USB.", + "title": "USB de Esfera Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Esfera Crownstone" + }, + "description": "Selecciona una Esfera Crownstone donde se encuentra el USB.", + "title": "USB de Esfera Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/et.json b/homeassistant/components/crownstone/translations/et.json new file mode 100644 index 00000000000..3a651257e1a --- /dev/null +++ b/homeassistant/components/crownstone/translations/et.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "usb_setup_complete": "Crownstone'i USB seadistamine on l\u00f5petatud.", + "usb_setup_unsuccessful": "Crownstone'i USB seadistamine nurjus." + }, + "error": { + "account_not_verified": "Konto pole kinnitatud. Aktiveeri oma konto Crownstone'i aktiveerimismeili kaudu.", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport v\u00f5i vali '\u00c4ra kasuta USB-d' kui ei soovi USB seadet h\u00e4\u00e4lestada. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + }, + "title": "Crownstone'i konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere kus USB asub.", + "use_usb_option": "Kasuta Crownstone'i USB seadet kohalikuks andmeedastuseks" + } + }, + "usb_config": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_config_option": { + "data": { + "usb_path": "USB seadme rada" + }, + "description": "Vali Crownstone'i USB seadme jadaport. \n\n Otsi seadet mille VID on 10C4 ja PID on EA60.", + "title": "Crownstone'i USB seadme s\u00e4tted" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB seadme rada" + }, + "description": "Sisesta k\u00e4sitsi Crownstone'i USBseadme rada.", + "title": "Crownstone'i USB seadme rada" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Vali Crownstone Sphere kus USB asub.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/he.json b/homeassistant/components/crownstone/translations/he.json new file mode 100644 index 00000000000..af11b65839b --- /dev/null +++ b/homeassistant/components/crownstone/translations/he.json @@ -0,0 +1,53 @@ +{ + "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", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + }, + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_config_option": { + "data": { + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/hu.json b/homeassistant/components/crownstone/translations/hu.json new file mode 100644 index 00000000000..2c2a2e34fe1 --- /dev/null +++ b/homeassistant/components/crownstone/translations/hu.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "usb_setup_complete": "A Crownstone USB be\u00e1ll\u00edt\u00e1sa befejez\u0151d\u00f6tt.", + "usb_setup_unsuccessful": "A Crownstone USB be\u00e1ll\u00edt\u00e1sa sikertelen volt." + }, + "error": { + "account_not_verified": "Nem ellen\u0151rz\u00f6tt fi\u00f3k. K\u00e9rj\u00fck, aktiv\u00e1lja fi\u00f3kj\u00e1t a Crownstone-t\u00f3l kapott aktiv\u00e1l\u00f3 e-mailben.", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t, vagy v\u00e1lassza 'Ne haszn\u00e1ljon USB-t' ha nem szerenke egy USB kulcsot be\u00e1ll\u00edtani most.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + }, + "title": "Crownstone fi\u00f3k" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, ahol az USB kulcs tal\u00e1lhat\u00f3", + "use_usb_option": "Crownstone USB-kulcs haszn\u00e1lata a helyi adat\u00e1tvitelhez" + } + }, + "usb_config": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_config_option": { + "data": { + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "V\u00e1lassza ki a Crownstone USB kulcs soros portj\u00e1t.\n\nKeressen egy VID 10C4 \u00e9s PID EA60 azonos\u00edt\u00f3val rendelkez\u0151 eszk\u00f6zt.", + "title": "Crownstone USB kulcs konfigur\u00e1ci\u00f3" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "description": "Adja meg manu\u00e1lisan a Crownstone USB kulcs \u00fatvonal\u00e1t.", + "title": "A Crownstone USB kulcs manu\u00e1lis el\u00e9r\u00e9si \u00fatja" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "V\u00e1lasszon egy Crownstone Sphere-t, ahol az USB tal\u00e1lhat\u00f3.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/id.json b/homeassistant/components/crownstone/translations/id.json new file mode 100644 index 00000000000..5bd28168d9a --- /dev/null +++ b/homeassistant/components/crownstone/translations/id.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Jalur Perangkat USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + } + }, + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + }, + "options": { + "step": { + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/it.json b/homeassistant/components/crownstone/translations/it.json new file mode 100644 index 00000000000..1fb43e75684 --- /dev/null +++ b/homeassistant/components/crownstone/translations/it.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "usb_setup_complete": "Configurazione USB Crownstone completata.", + "usb_setup_unsuccessful": "La configurazione USB di Crownstone non ha avuto successo." + }, + "error": { + "account_not_verified": "Account non verificato. Attiva il tuo account tramite l'e-mail di attivazione di Crownstone.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera di Crownstone dove si trova l'USB.", + "title": "Sfera USB Crownstone" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Password" + }, + "title": "Account Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Sfera di Crownstone dove si trova l'USB", + "use_usb_option": "Utilizzare una chiavetta USB Crownstone per la trasmissione locale dei dati" + } + }, + "usb_config": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Percorso del dispositivo USB" + }, + "description": "Seleziona la porta seriale della chiavetta USB Crownstone. \n\nCerca un dispositivo con VID 10C4 e PID EA60.", + "title": "Configurazione della chiavetta USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "Percorso del dispositivo USB" + }, + "description": "Immettere manualmente il percorso di una chiavetta USB Crownstone.", + "title": "Percorso manuale della chiavetta USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera di Crownstone dove si trova l'USB.", + "title": "Sfera USB Crownstone" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Sfera Crownstone" + }, + "description": "Seleziona una sfera Crownstone in cui si trova l'USB.", + "title": "Sfera USB Crownstone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ko.json b/homeassistant/components/crownstone/translations/ko.json new file mode 100644 index 00000000000..aadd2d3da42 --- /dev/null +++ b/homeassistant/components/crownstone/translations/ko.json @@ -0,0 +1,16 @@ +{ + "options": { + "step": { + "usb_config_option": { + "data": { + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB \uc7a5\uce58 \uacbd\ub85c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/nl.json b/homeassistant/components/crownstone/translations/nl.json new file mode 100644 index 00000000000..1da12c8f841 --- /dev/null +++ b/homeassistant/components/crownstone/translations/nl.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "usb_setup_complete": "Crownstone USB installatie voltooid.", + "usb_setup_unsuccessful": "Crownstone USB installatie is mislukt." + }, + "error": { + "account_not_verified": "Account niet geverifieerd. Gelieve uw account te activeren via de activeringsmail van Crownstone.", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle, of selecteer 'Don't use USB' als u geen USB dongle wilt instellen.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "title": "Crownstone account" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere waar de USB zich bevindt", + "use_usb_option": "Gebruik een Crownstone USB-dongle voor lokale gegevensoverdracht" + } + }, + "usb_config": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_config_option": { + "data": { + "usb_path": "USB-apparaatpad" + }, + "description": "Selecteer de seri\u00eble poort van de Crownstone USB dongle.\n\nZoek naar een apparaat met VID 10C4 en PID EA60.", + "title": "Crownstone USB dongle configuratie" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB-apparaatpad" + }, + "description": "Voer handmatig het pad van een Crownstone USB dongle in.", + "title": "Crownstone USB-dongle handmatig pad" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Selecteer een Crownstone Sphere waar de USB zich bevindt.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/no.json b/homeassistant/components/crownstone/translations/no.json new file mode 100644 index 00000000000..88f3578a9a4 --- /dev/null +++ b/homeassistant/components/crownstone/translations/no.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "usb_setup_complete": "Crownstone USB -oppsett fullf\u00f8rt.", + "usb_setup_unsuccessful": "Crownstone USB -oppsett mislyktes." + }, + "error": { + "account_not_verified": "Kontoen er ikke bekreftet. Vennligst aktiver kontoen din via aktiverings -e -posten fra Crownstone.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg den serielle porten p\u00e5 Crownstone USB -dongelen, eller velg 'Ikke bruk USB' hvis du ikke vil konfigurere en USB -dongle. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone USB Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "title": "Crownstone -konto" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Velg en Crownstone Sphere der USB -en er plassert.", + "use_usb_option": "Bruk en Crownstone USB -dongle for lokal dataoverf\u00f8ring" + } + }, + "usb_config": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg serieporten til Crownstone USB -dongelen. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_config_option": { + "data": { + "usb_path": "USB enhetsbane" + }, + "description": "Velg serieporten til Crownstone USB -dongelen. \n\n Se etter en enhet med VID 10C4 og PID EA60.", + "title": "Crownstone USB -dongle -konfigurasjon" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB enhetsbane" + }, + "description": "Skriv inn banen til en Crownstone USB -dongle manuelt.", + "title": "Crownstone USB -dongle manuell bane" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone USB Sphere" + }, + "description": "Velg en Crownstone Sphere der USB -en er plassert.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/ru.json b/homeassistant/components/crownstone/translations/ru.json new file mode 100644 index 00000000000..7dfd88bd63e --- /dev/null +++ b/homeassistant/components/crownstone/translations/ru.json @@ -0,0 +1,96 @@ +{ + "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.", + "usb_setup_complete": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Crownstone USB \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430.", + "usb_setup_unsuccessful": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Crownstone USB \u043d\u0435 \u0443\u0434\u0430\u043b\u0430\u0441\u044c." + }, + "error": { + "account_not_verified": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u0430. \u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0439\u0442\u0435 \u0435\u0451 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0438\u0441\u044c\u043c\u0430, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043f\u043e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u0435.", + "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": { + "usb_config": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone \u0438\u043b\u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 'Don't use USB', \u0435\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c \u0435\u0433\u043e. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + }, + "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" + }, + "title": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Crownstone" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "use_usb_option": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Crownstone \u0434\u043b\u044f \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0434\u0430\u043d\u043d\u044b\u0445" + } + }, + "usb_config": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone. \n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0431\u0438\u0440\u0430\u0439\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 VID 10C4 \u0438 PID EA60.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone.", + "title": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Crownstone Sphere, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/zh-Hant.json b/homeassistant/components/crownstone/translations/zh-Hant.json new file mode 100644 index 00000000000..2c362ba0bcb --- /dev/null +++ b/homeassistant/components/crownstone/translations/zh-Hant.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "usb_setup_complete": "Crownstone USB \u8a2d\u5b9a\u5b8c\u6210\u3002", + "usb_setup_unsuccessful": "Crownstone USB \u8a2d\u5b9a\u6210\u529f\u3002" + }, + "error": { + "account_not_verified": "\u5e33\u865f\u5c1a\u672a\u9a57\u8b49\u3001\u8acb\u900f\u904e\u4f86\u81ea Crownstone \u7684\u9a57\u8b49\u90f5\u4ef6\u555f\u52d5\u60a8\u7684\u5e33\u865f\u3002", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\uff0c\u6216\u5047\u5982\u60a8\u4e0d\u60f3\u8a2d\u5b9a USB \u88dd\u7f6e\u7684\u8a71\u3001\u8acb\u9078\u64c7 '\u4e0d\u4f7f\u7528 USB'\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "title": "Crownstone \u5e33\u865f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere \u6240\u5728 USB \u8def\u5f91", + "use_usb_option": "\u4f7f\u7528 Crownstone USB \u88dd\u7f6e\u9032\u884c\u672c\u5730\u7aef\u8cc7\u6599\u50b3\u8f38" + } + }, + "usb_config": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_config_option": { + "data": { + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u9078\u64c7 Crownstone USB \u88dd\u7f6e\u5e8f\u5217\u57e0\u3002\n\n\u8acb\u641c\u5c0b VID 10C4 \u53ca PID EA60 \u88dd\u7f6e\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u624b\u52d5\u8f38\u5165 Crownstone USB \u88dd\u7f6e\u8def\u5f91\u3002", + "title": "Crownstone USB \u88dd\u7f6e\u624b\u52d5\u8def\u5f91" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "\u9078\u64c7 Crownstone Sphere \u6240\u5728 USB \u8def\u5f91\u3002", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index ea0709e5557..43d169e3440 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -75,7 +75,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): uuid=uuid, password=password, ) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, ClientError): + self.host = None return self.async_show_form( step_id="user", data_schema=self.schema, @@ -87,13 +88,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=self.schema, errors={"base": "invalid_auth"}, ) - except ClientError: - _LOGGER.exception("ClientError") - return self.async_show_form( - step_id="user", - data_schema=self.schema, - errors={"base": "unknown"}, - ) except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error creating device") return self.async_show_form( @@ -109,6 +103,13 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """User initiated config flow.""" if user_input is None: return self.async_show_form(step_id="user", data_schema=self.schema) + if user_input.get(CONF_API_KEY) and user_input.get(CONF_PASSWORD): + self.host = user_input.get(CONF_HOST) + return self.async_show_form( + step_id="user", + data_schema=self.schema, + errors={"base": "api_password"}, + ) return await self._create_device( user_input[CONF_HOST], user_input.get(CONF_API_KEY), diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index b03c8eb113d..e0222d308ea 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -1,21 +1,4 @@ """Constants for Daikin.""" -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_TYPE, - CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENERGY_KILO_WATT_HOUR, - FREQUENCY_HERTZ, - PERCENTAGE, - POWER_KILO_WATT, - TEMP_CELSIUS, -) - DOMAIN = "daikin" ATTR_TARGET_TEMPERATURE = "target_temperature" @@ -31,65 +14,6 @@ ATTR_COMPRESSOR_FREQUENCY = "compressor_frequency" ATTR_STATE_ON = "on" ATTR_STATE_OFF = "off" -SENSOR_TYPE_TEMPERATURE = "temperature" -SENSOR_TYPE_HUMIDITY = "humidity" -SENSOR_TYPE_POWER = "power" -SENSOR_TYPE_ENERGY = "energy" -SENSOR_TYPE_FREQUENCY = "frequency" - -SENSOR_TYPES = { - ATTR_INSIDE_TEMPERATURE: { - CONF_NAME: "Inside Temperature", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - }, - ATTR_OUTSIDE_TEMPERATURE: { - CONF_NAME: "Outside Temperature", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - }, - ATTR_HUMIDITY: { - CONF_NAME: "Humidity", - CONF_TYPE: SENSOR_TYPE_HUMIDITY, - CONF_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - ATTR_TARGET_HUMIDITY: { - CONF_NAME: "Target Humidity", - CONF_TYPE: SENSOR_TYPE_HUMIDITY, - CONF_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - ATTR_TOTAL_POWER: { - CONF_NAME: "Total Power Consumption", - CONF_TYPE: SENSOR_TYPE_POWER, - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, - }, - ATTR_COOL_ENERGY: { - CONF_NAME: "Cool Energy Consumption", - CONF_TYPE: SENSOR_TYPE_ENERGY, - CONF_ICON: "mdi:snowflake", - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, - ATTR_HEAT_ENERGY: { - CONF_NAME: "Heat Energy Consumption", - CONF_TYPE: SENSOR_TYPE_ENERGY, - CONF_ICON: "mdi:fire", - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, - ATTR_COMPRESSOR_FREQUENCY: { - CONF_NAME: "Compressor Frequency", - CONF_TYPE: SENSOR_TYPE_FREQUENCY, - CONF_ICON: "mdi:fan", - CONF_UNIT_OF_MEASUREMENT: FREQUENCY_HERTZ, - }, -} - CONF_UUID = "uuid" KEY_MAC = "mac" diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 0defa633387..62d3e8e1f7e 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -1,11 +1,22 @@ """Support for Daikin AC sensors.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pydaikin.daikin_base import Appliance + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_TYPE, - CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_KILO_WATT, + TEMP_CELSIUS, ) from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi @@ -18,12 +29,80 @@ from .const import ( ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_HUMIDITY, ATTR_TOTAL_POWER, - SENSOR_TYPE_ENERGY, - SENSOR_TYPE_FREQUENCY, - SENSOR_TYPE_HUMIDITY, - SENSOR_TYPE_POWER, - SENSOR_TYPE_TEMPERATURE, - SENSOR_TYPES, +) + + +@dataclass +class DaikinRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[Appliance], float | None] + + +@dataclass +class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysMixin): + """Describes Daikin sensor entity.""" + + +SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( + DaikinSensorEntityDescription( + key=ATTR_INSIDE_TEMPERATURE, + name="Inside Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + value_func=lambda device: device.inside_temperature, + ), + DaikinSensorEntityDescription( + key=ATTR_OUTSIDE_TEMPERATURE, + name="Outside Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + value_func=lambda device: device.outside_temperature, + ), + DaikinSensorEntityDescription( + key=ATTR_HUMIDITY, + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_func=lambda device: device.humidity, + ), + DaikinSensorEntityDescription( + key=ATTR_TARGET_HUMIDITY, + name="Target Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_func=lambda device: device.humidity, + ), + DaikinSensorEntityDescription( + key=ATTR_TOTAL_POWER, + name="Total Power Consumption", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_KILO_WATT, + value_func=lambda device: round(device.current_total_power_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_COOL_ENERGY, + name="Cool Energy Consumption", + icon="mdi:snowflake", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_func=lambda device: round(device.last_hour_cool_energy_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_HEAT_ENERGY, + name="Heat Energy Consumption", + icon="mdi:fire", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_func=lambda device: round(device.last_hour_heat_energy_consumption, 2), + ), + DaikinSensorEntityDescription( + key=ATTR_COMPRESSOR_FREQUENCY, + name="Compressor Frequency", + icon="mdi:fan", + native_unit_of_measurement=FREQUENCY_HERTZ, + value_func=lambda device: device.compressor_frequency, + ), ) @@ -50,60 +129,37 @@ async def async_setup_entry(hass, entry, async_add_entities): sensors.append(ATTR_TARGET_HUMIDITY) if daikin_api.device.support_compressor_frequency: sensors.append(ATTR_COMPRESSOR_FREQUENCY) - async_add_entities([DaikinSensor.factory(daikin_api, sensor) for sensor in sensors]) + + entities = [ + DaikinSensor(daikin_api, description) + for description in SENSOR_TYPES + if description.key in sensors + ] + async_add_entities(entities) class DaikinSensor(SensorEntity): """Representation of a Sensor.""" - @staticmethod - def factory(api: DaikinApi, monitored_state: str): - """Initialize any DaikinSensor.""" - cls = { - SENSOR_TYPE_TEMPERATURE: DaikinClimateSensor, - SENSOR_TYPE_HUMIDITY: DaikinClimateSensor, - SENSOR_TYPE_POWER: DaikinPowerSensor, - SENSOR_TYPE_ENERGY: DaikinPowerSensor, - SENSOR_TYPE_FREQUENCY: DaikinClimateSensor, - }[SENSOR_TYPES[monitored_state][CONF_TYPE]] - return cls(api, monitored_state) + entity_description: DaikinSensorEntityDescription - def __init__(self, api: DaikinApi, monitored_state: str) -> None: + def __init__( + self, api: DaikinApi, description: DaikinSensorEntityDescription + ) -> None: """Initialize the sensor.""" + self.entity_description = description self._api = api - self._sensor = SENSOR_TYPES[monitored_state] - self._name = f"{api.name} {self._sensor[CONF_NAME]}" - self._device_attribute = monitored_state + self._attr_name = f"{api.name} {description.name}" @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.device.mac}-{self._device_attribute}" + return f"{self._api.device.mac}-{self.entity_description.key}" @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" - raise NotImplementedError - - @property - def device_class(self): - """Return the class of this device.""" - return self._sensor.get(CONF_DEVICE_CLASS) - - @property - def icon(self): - """Return the icon of this device.""" - return self._sensor.get(CONF_ICON) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._sensor[CONF_UNIT_OF_MEASUREMENT] + return self.entity_description.value_func(self._api.device) async def async_update(self): """Retrieve latest state.""" @@ -113,40 +169,3 @@ class DaikinSensor(SensorEntity): def device_info(self): """Return a device description for device registry.""" return self._api.device_info - - -class DaikinClimateSensor(DaikinSensor): - """Representation of a Climate Sensor.""" - - @property - def native_value(self): - """Return the internal state of the sensor.""" - if self._device_attribute == ATTR_INSIDE_TEMPERATURE: - return self._api.device.inside_temperature - if self._device_attribute == ATTR_OUTSIDE_TEMPERATURE: - return self._api.device.outside_temperature - - if self._device_attribute == ATTR_HUMIDITY: - return self._api.device.humidity - if self._device_attribute == ATTR_TARGET_HUMIDITY: - return self._api.device.target_humidity - - if self._device_attribute == ATTR_COMPRESSOR_FREQUENCY: - return self._api.device.compressor_frequency - - return None - - -class DaikinPowerSensor(DaikinSensor): - """Representation of a power/energy consumption sensor.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - if self._device_attribute == ATTR_TOTAL_POWER: - return round(self._api.device.current_total_power_consumption, 2) - if self._device_attribute == ATTR_COOL_ENERGY: - return round(self._api.device.last_hour_cool_energy_consumption, 2) - if self._device_attribute == ATTR_HEAT_ENERGY: - return round(self._api.device.last_hour_heat_energy_consumption, 2) - return None diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index fc2b6e79a5e..5c759384795 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -18,6 +18,7 @@ "error": { "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "api_password": "[%key:common::config_flow::error::invalid_auth%], use either API Key or Password.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } diff --git a/homeassistant/components/daikin/translations/en.json b/homeassistant/components/daikin/translations/en.json index d1db170d769..84843ba8211 100644 --- a/homeassistant/components/daikin/translations/en.json +++ b/homeassistant/components/daikin/translations/en.json @@ -5,6 +5,7 @@ "cannot_connect": "Failed to connect" }, "error": { + "api_password": "Invalid authentication, use either API Key or Password.", "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" diff --git a/homeassistant/components/daikin/translations/fr.json b/homeassistant/components/daikin/translations/fr.json index ab193dc20af..8d033bdb853 100644 --- a/homeassistant/components/daikin/translations/fr.json +++ b/homeassistant/components/daikin/translations/fr.json @@ -13,8 +13,8 @@ "user": { "data": { "api_key": "Cl\u00e9 d'API", - "host": "Nom d'h\u00f4te ou adresse IP", - "password": "Mot de passe de l'appareil (utilis\u00e9 uniquement par les appareils SKYFi)" + "host": "H\u00f4te", + "password": "Mot de passe" }, "description": "Saisissez l'adresse IP de votre Daikin AC. \n\n Notez que Cl\u00e9 d'API et Mot de passe sont utilis\u00e9s respectivement par les p\u00e9riph\u00e9riques BRP072Cxx et SKYFi.", "title": "Configurer Daikin AC" diff --git a/homeassistant/components/daikin/translations/hu.json b/homeassistant/components/daikin/translations/hu.json index f1cb7eab8f6..6049890cb53 100644 --- a/homeassistant/components/daikin/translations/hu.json +++ b/homeassistant/components/daikin/translations/hu.json @@ -13,10 +13,10 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3" }, - "description": "Add meg a Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 IP-c\u00edm\u00e9t.", + "description": "Adja meg Daikin k\u00e9sz\u00fcl\u00e9k\u00e9nek az IP c\u00edm\u00e9t.\n\nNe feledje, hogy z API kulcs \u00e9s a Jelsz\u00f3 funkci\u00f3t csak a BRP072Cxx \u00e9s a SKYFi eszk\u00f6z\u00f6k haszn\u00e1lj\u00e1k.", "title": "A Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 konfigur\u00e1l\u00e1sa" } } diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 3ff5d087e14..8d6cab13e62 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.4.1"], + "requirements": ["debugpy==1.4.3"], "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index c0ebc9d3134..73e85f13713 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -55,7 +55,7 @@ DECONZ_TO_ALARM_STATE = { def get_alarm_system_for_unique_id(gateway, unique_id: str): """Retrieve alarm system unique ID is registered to.""" - for alarm_system in gateway.api.alarm_systems.values(): + for alarm_system in gateway.api.alarmsystems.values(): if unique_id in alarm_system.devices: return alarm_system @@ -76,9 +76,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: for sensor in sensors: if ( - sensor.type in AncillaryControl.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] - and get_alarm_system_for_unique_id(gateway, sensor.uniqueid) + isinstance(sensor, AncillaryControl) + and sensor.unique_id not in gateway.entities[DOMAIN] + and get_alarm_system_for_unique_id(gateway, sensor.unique_id) ): entities.append(DeconzAlarmControlPanel(sensor, gateway)) @@ -110,7 +110,7 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): def __init__(self, device, gateway) -> None: """Set up alarm control panel device.""" super().__init__(device, gateway) - self.alarm_system = get_alarm_system_for_unique_id(gateway, device.uniqueid) + self.alarm_system = get_alarm_system_for_unique_id(gateway, device.unique_id) @callback def async_update_callback(self, force_update: bool = False) -> None: diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 392d2e03885..33b68f25cab 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,5 +1,14 @@ """Support for deCONZ binary sensors.""" -from pydeconz.sensor import CarbonMonoxide, Fire, OpenClose, Presence, Vibration, Water +from pydeconz.sensor import ( + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + OpenClose, + Presence, + Vibration, + Water, +) from homeassistant.components.binary_sensor import ( DEVICE_CLASS_GAS, @@ -11,6 +20,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_VIBRATION, DOMAIN, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import callback @@ -20,17 +30,46 @@ from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry +DECONZ_BINARY_SENSORS = ( + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + OpenClose, + Presence, + Vibration, + Water, +) + ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" ATTR_VIBRATIONSTRENGTH = "vibrationstrength" -DEVICE_CLASS = { - CarbonMonoxide: DEVICE_CLASS_GAS, - Fire: DEVICE_CLASS_SMOKE, - OpenClose: DEVICE_CLASS_OPENING, - Presence: DEVICE_CLASS_MOTION, - Vibration: DEVICE_CLASS_VIBRATION, - Water: DEVICE_CLASS_MOISTURE, +ENTITY_DESCRIPTIONS = { + CarbonMonoxide: BinarySensorEntityDescription( + key="carbonmonoxide", + device_class=DEVICE_CLASS_GAS, + ), + Fire: BinarySensorEntityDescription( + key="fire", + device_class=DEVICE_CLASS_SMOKE, + ), + OpenClose: BinarySensorEntityDescription( + key="openclose", + device_class=DEVICE_CLASS_OPENING, + ), + Presence: BinarySensorEntityDescription( + key="presence", + device_class=DEVICE_CLASS_MOTION, + ), + Vibration: BinarySensorEntityDescription( + key="vibration", + device_class=DEVICE_CLASS_VIBRATION, + ), + Water: BinarySensorEntityDescription( + key="water", + device_class=DEVICE_CLASS_MOISTURE, + ), } @@ -46,13 +85,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: + if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): + continue + if ( - sensor.BINARY - and sensor.uniqueid not in gateway.entities[DOMAIN] - and ( - gateway.option_allow_clip_sensor - or not sensor.type.startswith("CLIP") - ) + isinstance(sensor, DECONZ_BINARY_SENSORS) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzBinarySensor(sensor, gateway)) @@ -84,7 +122,9 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): def __init__(self, device, gateway): """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) - self._attr_device_class = DEVICE_CLASS.get(type(self._device)) + + if entity_description := ENTITY_DESCRIPTIONS.get(type(device)): + self.entity_description = entity_description @callback def async_update_callback(self, force_update=False): @@ -116,8 +156,8 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): elif self._device.type in Vibration.ZHATYPE: attr[ATTR_ORIENTATION] = self._device.orientation - attr[ATTR_TILTANGLE] = self._device.tiltangle - attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength + attr[ATTR_TILTANGLE] = self._device.tilt_angle + attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibration_strength return attr diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 4f8345b5e92..86c19be3cd2 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -84,13 +84,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: + if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): + continue + if ( - sensor.type in Thermostat.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] - and ( - gateway.option_allow_clip_sensor - or not sensor.type.startswith("CLIP") - ) + isinstance(sensor, Thermostat) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzThermostat(sensor, gateway)) @@ -142,7 +141,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): def fan_mode(self) -> str: """Return fan operation.""" return DECONZ_TO_FAN_MODE.get( - self._device.fanmode, FAN_ON if self._device.state_on else FAN_OFF + self._device.fan_mode, FAN_ON if self._device.state_on else FAN_OFF ) @property @@ -155,9 +154,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if fan_mode not in FAN_MODE_TO_DECONZ: raise ValueError(f"Unsupported fan mode {fan_mode}") - data = {"fanmode": FAN_MODE_TO_DECONZ[fan_mode]} - - await self._device.async_set_config(data) + await self._device.set_config(fan_mode=FAN_MODE_TO_DECONZ[fan_mode]) # HVAC control @@ -186,7 +183,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if len(self._hvac_mode_to_deconz) == 2: # Only allow turn on and off thermostat data = {"on": self._hvac_mode_to_deconz[hvac_mode]} - await self._device.async_set_config(data) + await self._device.set_config(**data) # Preset control @@ -205,9 +202,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if preset_mode not in PRESET_MODE_TO_DECONZ: raise ValueError(f"Unsupported preset mode {preset_mode}") - data = {"preset": PRESET_MODE_TO_DECONZ[preset_mode]} - - await self._device.async_set_config(data) + await self._device.set_config(preset=PRESET_MODE_TO_DECONZ[preset_mode]) # Temperature control @@ -220,19 +215,19 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): def target_temperature(self) -> float: """Return the target temperature.""" if self._device.mode == "cool": - return self._device.coolsetpoint - return self._device.heatsetpoint + return self._device.cooling_setpoint + return self._device.heating_setpoint async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - data = {"heatsetpoint": kwargs[ATTR_TEMPERATURE] * 100} + data = {"heating_setpoint": kwargs[ATTR_TEMPERATURE] * 100} if self._device.mode == "cool": - data = {"coolsetpoint": kwargs[ATTR_TEMPERATURE] * 100} + data = {"cooling_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - await self._device.async_set_config(data) + await self._device.set_config(**data) @property def extra_state_attributes(self): diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 2f15aaa50cc..3a6c5aecfb5 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -5,10 +5,10 @@ from urllib.parse import urlparse import async_timeout from pydeconz.errors import RequestError, ResponseError +from pydeconz.gateway import DeconzSession from pydeconz.utils import ( - async_discovery, - async_get_api_key, - async_get_bridge_id, + discovery as deconz_discovery, + get_bridge_id as deconz_get_bridge_id, normalize_bridge_id, ) import voluptuous as vol @@ -86,7 +86,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: with async_timeout.timeout(10): - self.bridges = await async_discovery(session) + self.bridges = await deconz_discovery(session) except (asyncio.TimeoutError, ResponseError): self.bridges = [] @@ -134,10 +134,15 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: session = aiohttp_client.async_get_clientsession(self.hass) + deconz_session = DeconzSession( + session, + host=self.deconz_config[CONF_HOST], + port=self.deconz_config[CONF_PORT], + ) try: with async_timeout.timeout(10): - api_key = await async_get_api_key(session, **self.deconz_config) + api_key = await deconz_session.get_api_key() except (ResponseError, RequestError, asyncio.TimeoutError): errors["base"] = "no_key" @@ -155,7 +160,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: with async_timeout.timeout(10): - self.bridge_id = await async_get_bridge_id( + self.bridge_id = await deconz_get_bridge_id( session, **self.deconz_config ) await self.async_set_unique_id(self.bridge_id) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index d2d7025771e..67753aa0355 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -12,6 +12,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN LOGGER = logging.getLogger(__package__) @@ -41,6 +42,7 @@ PLATFORMS = [ LOCK_DOMAIN, SCENE_DOMAIN, SENSOR_DOMAIN, + SIREN_DOMAIN, SWITCH_DOMAIN, ] @@ -55,24 +57,8 @@ ATTR_OFFSET = "offset" ATTR_ON = "on" ATTR_VALVE = "valve" -# Covers -LEVEL_CONTROLLABLE_OUTPUT = "Level controllable output" -DAMPERS = [LEVEL_CONTROLLABLE_OUTPUT] -WINDOW_COVERING_CONTROLLER = "Window covering controller" -WINDOW_COVERING_DEVICE = "Window covering device" -WINDOW_COVERS = [WINDOW_COVERING_CONTROLLER, WINDOW_COVERING_DEVICE] -COVER_TYPES = DAMPERS + WINDOW_COVERS - -# Fans -FANS = ["Fan"] - -# Locks -LOCK_TYPES = ["Door Lock", "ZHADoorLock"] - # Switches POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] -SIRENS = ["Warning device"] -SWITCH_TYPES = POWER_PLUGS + SIRENS CONF_ANGLE = "angle" CONF_GESTURE = "gesture" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 21618127905..abf1fe4eea4 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -1,4 +1,7 @@ """Support for deCONZ covers.""" + +from pydeconz.light import Cover + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -18,20 +21,14 @@ from homeassistant.components.cover import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - COVER_TYPES, - LEVEL_CONTROLLABLE_OUTPUT, - NEW_LIGHT, - WINDOW_COVERING_CONTROLLER, - WINDOW_COVERING_DEVICE, -) +from .const import NEW_LIGHT from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry DEVICE_CLASS = { - LEVEL_CONTROLLABLE_OUTPUT: DEVICE_CLASS_DAMPER, - WINDOW_COVERING_CONTROLLER: DEVICE_CLASS_SHADE, - WINDOW_COVERING_DEVICE: DEVICE_CLASS_SHADE, + "Level controllable output": DEVICE_CLASS_DAMPER, + "Window covering controller": DEVICE_CLASS_SHADE, + "Window covering device": DEVICE_CLASS_SHADE, } @@ -47,8 +44,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type in COVER_TYPES - and light.uniqueid not in gateway.entities[DOMAIN] + isinstance(light, Cover) + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzCover(light, gateway)) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 63f624ba643..fe9eaa8ff60 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -1,4 +1,5 @@ """Base class for deCONZ devices.""" + from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,15 +19,15 @@ class DeconzBase: @property def unique_id(self): """Return a unique identifier for this device.""" - return self._device.uniqueid + return self._device.unique_id @property def serial(self): """Return a serial number for this device.""" - if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: + if self._device.unique_id is None or self._device.unique_id.count(":") != 7: return None - return self._device.uniqueid.split("-", 1)[0] + return self._device.unique_id.split("-", 1)[0] @property def device_info(self): @@ -38,10 +39,10 @@ class DeconzBase: "connections": {(CONNECTION_ZIGBEE, self.serial)}, "identifiers": {(DECONZ_DOMAIN, self.serial)}, "manufacturer": self._device.manufacturer, - "model": self._device.modelid, + "model": self._device.model_id, "name": self._device.name, - "sw_version": self._device.swversion, - "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridgeid), + "sw_version": self._device.software_version, + "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridge_id), } @@ -59,14 +60,6 @@ class DeconzDevice(DeconzBase, Entity): self._attr_name = self._device.name - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry. - - Daylight is a virtual sensor from deCONZ that should never be enabled by default. - """ - return self._device.type != "Daylight" - async def async_added_to_hass(self): """Subscribe to device events.""" self._device.register_callback(self.async_update_callback) diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 2fa9ec87fe3..2d9799c4d02 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -40,23 +40,24 @@ async def async_setup_events(gateway) -> None: @callback def async_add_sensor(sensors=gateway.api.sensors.values()): """Create DeconzEvent.""" + new_events = [] + known_events = {event.unique_id for event in gateway.events} + for sensor in sensors: if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"): continue - if ( - sensor.type not in Switch.ZHATYPE + AncillaryControl.ZHATYPE - or sensor.uniqueid in {event.unique_id for event in gateway.events} - ): + if sensor.unique_id in known_events: continue - if sensor.type in Switch.ZHATYPE: - new_event = DeconzEvent(sensor, gateway) + if isinstance(sensor, Switch): + new_events.append(DeconzEvent(sensor, gateway)) - elif sensor.type in AncillaryControl.ZHATYPE: - new_event = DeconzAlarmEvent(sensor, gateway) + elif isinstance(sensor, AncillaryControl): + new_events.append(DeconzAlarmEvent(sensor, gateway)) + for new_event in new_events: gateway.hass.async_create_task(new_event.async_update_device_registry()) gateway.events.append(new_event) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index cb64bff6d16..38fc087cdfd 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -1,6 +1,8 @@ """Support for deCONZ fans.""" from __future__ import annotations +from pydeconz.light import Fan + from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, @@ -17,7 +19,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .const import FANS, NEW_LIGHT +from .const import NEW_LIGHT from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -39,7 +41,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: for light in lights: - if light.type in FANS and light.uniqueid not in gateway.entities[DOMAIN]: + if ( + isinstance(light, Fan) + and light.unique_id not in gateway.entities[DOMAIN] + ): entities.append(DeconzFan(light, gateway)) if entities: diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 0a7d7e0c849..ecbc36ebadc 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -151,11 +151,11 @@ class DeconzGateway: # Gateway service device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, - identifiers={(DECONZ_DOMAIN, self.api.config.bridgeid)}, + identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", - model=self.api.config.modelid, + model=self.api.config.model_id, name=self.api.config.name, - sw_version=self.api.config.swversion, + sw_version=self.api.config.software_version, via_device=(CONNECTION_NETWORK_MAC, self.api.config.mac), ) @@ -266,12 +266,12 @@ async def get_gateway( config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], - async_add_device=async_add_device_callback, + add_device=async_add_device_callback, connection_status=async_connection_status_callback, ) try: with async_timeout.timeout(10): - await deconz.initialize() + await deconz.refresh_state() return deconz except errors.Unauthorized as err: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 058147189e6..2202bdbe58f 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pydeconz.light import Light + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -28,18 +30,10 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.color import color_hs_to_xy -from .const import ( - COVER_TYPES, - DOMAIN as DECONZ_DOMAIN, - LOCK_TYPES, - NEW_GROUP, - NEW_LIGHT, - SWITCH_TYPES, -) +from .const import DOMAIN as DECONZ_DOMAIN, NEW_GROUP, NEW_LIGHT, POWER_PLUGS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -CONTROLLER = ["Configuration tool"] DECONZ_GROUP = "is_deconz_group" @@ -48,8 +42,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - other_light_resource_types = CONTROLLER + COVER_TYPES + LOCK_TYPES + SWITCH_TYPES - @callback def async_add_light(lights=gateway.api.lights.values()): """Add light from deCONZ.""" @@ -57,8 +49,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type not in other_light_resource_types - and light.uniqueid not in gateway.entities[DOMAIN] + isinstance(light, Light) + and light.type not in POWER_PLUGS + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLight(light, gateway)) @@ -112,10 +105,10 @@ class DeconzBaseLight(DeconzDevice, LightEntity): self._attr_supported_color_modes = set() - if device.ct is not None: + if device.color_temp is not None: self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) - if device.hue is not None and device.sat is not None: + if device.hue is not None and device.saturation is not None: self._attr_supported_color_modes.add(COLOR_MODE_HS) if device.xy is not None: @@ -137,11 +130,11 @@ class DeconzBaseLight(DeconzDevice, LightEntity): @property def color_mode(self) -> str: """Return the color mode of the light.""" - if self._device.colormode == "ct": + if self._device.color_mode == "ct": color_mode = COLOR_MODE_COLOR_TEMP - elif self._device.colormode == "hs": + elif self._device.color_mode == "hs": color_mode = COLOR_MODE_HS - elif self._device.colormode == "xy": + elif self._device.color_mode == "xy": color_mode = COLOR_MODE_XY elif self._device.brightness is not None: color_mode = COLOR_MODE_BRIGHTNESS @@ -162,12 +155,12 @@ class DeconzBaseLight(DeconzDevice, LightEntity): @property def color_temp(self): """Return the CT color value.""" - return self._device.ct + return self._device.color_temp @property def hs_color(self) -> tuple: """Return the hs color value.""" - return (self._device.hue / 65535 * 360, self._device.sat / 255 * 100) + return (self._device.hue / 65535 * 360, self._device.saturation / 255 * 100) @property def xy_color(self) -> tuple | None: @@ -184,25 +177,25 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data = {"on": True} if ATTR_BRIGHTNESS in kwargs: - data["bri"] = kwargs[ATTR_BRIGHTNESS] + data["brightness"] = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP in kwargs: - data["ct"] = kwargs[ATTR_COLOR_TEMP] + data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] if ATTR_HS_COLOR in kwargs: if COLOR_MODE_XY in self._attr_supported_color_modes: data["xy"] = color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) else: data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + data["saturation"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) if ATTR_XY_COLOR in kwargs: data["xy"] = kwargs[ATTR_XY_COLOR] if ATTR_TRANSITION in kwargs: - data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10) elif "IKEA" in self._device.manufacturer: - data["transitiontime"] = 0 + data["transition_time"] = 0 if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_SHORT: @@ -218,7 +211,7 @@ class DeconzBaseLight(DeconzDevice, LightEntity): else: data["effect"] = "none" - await self._device.async_set_state(data) + await self._device.set_state(**data) async def async_turn_off(self, **kwargs): """Turn off light.""" @@ -228,8 +221,8 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data = {"on": False} if ATTR_TRANSITION in kwargs: - data["bri"] = 0 - data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + data["brightness"] = 0 + data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_SHORT: @@ -239,7 +232,7 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data["alert"] = "lselect" del data["on"] - await self._device.async_set_state(data) + await self._device.set_state(**data) @property def extra_state_attributes(self): @@ -253,12 +246,12 @@ class DeconzLight(DeconzBaseLight): @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - return self._device.ctmax or super().max_mireds + return self._device.max_color_temp or super().max_mireds @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" - return self._device.ctmin or super().min_mireds + return self._device.min_color_temp or super().min_mireds class DeconzGroup(DeconzBaseLight): @@ -282,7 +275,7 @@ class DeconzGroup(DeconzBaseLight): "manufacturer": "Dresden Elektronik", "model": "deCONZ group", "name": self._device.name, - "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridgeid), + "via_device": (DECONZ_DOMAIN, self.gateway.api.config.bridge_id), } @property diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 75f6bc872db..bb23ec4be7a 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -1,9 +1,13 @@ """Support for deCONZ locks.""" + +from pydeconz.light import Lock +from pydeconz.sensor import DoorLock + from homeassistant.components.lock import DOMAIN, LockEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import LOCK_TYPES, NEW_LIGHT, NEW_SENSOR +from .const import NEW_LIGHT, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -21,8 +25,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type in LOCK_TYPES - and light.uniqueid not in gateway.entities[DOMAIN] + isinstance(light, Lock) + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(light, gateway)) @@ -43,8 +47,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: if ( - sensor.type in LOCK_TYPES - and sensor.uniqueid not in gateway.entities[DOMAIN] + isinstance(sensor, DoorLock) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLock(sensor, gateway)) diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index b36e06c0cf6..0d7ad67dda6 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -1,7 +1,7 @@ """Describe deCONZ logbook events.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT from homeassistant.core import HomeAssistant, callback @@ -153,9 +153,9 @@ def async_describe_events( interface = None data = event.data.get(CONF_EVENT) or event.data.get(CONF_GESTURE, "") - if data and deconz_event.device.modelid in REMOTES: + if data and deconz_event.device.model_id in REMOTES: action, interface = _get_device_event_description( - deconz_event.device.modelid, data + deconz_event.device.model_id, data ) # Unknown event diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 69deac8b5e0..a3dae8f5470 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==83" + "pydeconz==84" ], "ssdp": [ { diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index f4a4d328d22..45e891add28 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -51,4 +51,4 @@ class DeconzScene(Scene): async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" - await self._scene.async_set_state({}) + await self._scene.recall() diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 012e686534f..8b82c2fa7bf 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,10 +1,10 @@ """Support for deCONZ sensors.""" from pydeconz.sensor import ( - AncillaryControl, + AirQuality, Battery, Consumption, Daylight, - DoorLock, + GenericStatus, Humidity, LightLevel, Power, @@ -12,6 +12,7 @@ from pydeconz.sensor import ( Switch, Temperature, Thermostat, + Time, ) from homeassistant.components.sensor import ( @@ -19,6 +20,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( ATTR_TEMPERATURE, @@ -47,40 +49,71 @@ from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry +DECONZ_SENSORS = ( + AirQuality, + Consumption, + Daylight, + GenericStatus, + Humidity, + LightLevel, + Power, + Pressure, + Temperature, + Time, +) + ATTR_CURRENT = "current" ATTR_POWER = "power" 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, - Pressure: DEVICE_CLASS_PRESSURE, - Temperature: DEVICE_CLASS_TEMPERATURE, -} - -ICON = { - Daylight: "mdi:white-balance-sunny", - Pressure: "mdi:gauge", - Temperature: "mdi:thermometer", -} - -STATE_CLASS = { - Consumption: STATE_CLASS_TOTAL_INCREASING, - Humidity: STATE_CLASS_MEASUREMENT, - Pressure: STATE_CLASS_MEASUREMENT, - Temperature: STATE_CLASS_MEASUREMENT, -} - -UNIT_OF_MEASUREMENT = { - Consumption: ENERGY_KILO_WATT_HOUR, - Humidity: PERCENTAGE, - LightLevel: LIGHT_LUX, - Power: POWER_WATT, - Pressure: PRESSURE_HPA, - Temperature: TEMP_CELSIUS, +ENTITY_DESCRIPTIONS = { + Battery: SensorEntityDescription( + key="battery", + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + Consumption: SensorEntityDescription( + key="consumption", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + Daylight: SensorEntityDescription( + key="daylight", + icon="mdi:white-balance-sunny", + entity_registry_enabled_default=False, + ), + Humidity: SensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + LightLevel: SensorEntityDescription( + key="lightlevel", + device_class=DEVICE_CLASS_ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + ), + Power: SensorEntityDescription( + key="power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + Pressure: SensorEntityDescription( + key="pressure", + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PRESSURE_HPA, + ), + Temperature: SensorEntityDescription( + key="temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), } @@ -117,14 +150,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): battery_handler.create_tracker(sensor) if ( - not sensor.BINARY - and sensor.type - not in AncillaryControl.ZHATYPE - + Battery.ZHATYPE - + DoorLock.ZHATYPE - + Switch.ZHATYPE - + Thermostat.ZHATYPE - and sensor.uniqueid not in gateway.entities[DOMAIN] + isinstance(sensor, DECONZ_SENSORS) + and not isinstance(sensor, Thermostat) + and sensor.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzSensor(sensor, gateway)) @@ -157,12 +185,8 @@ class DeconzSensor(DeconzDevice, SensorEntity): """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) - self._attr_device_class = DEVICE_CLASS.get(type(self._device)) - self._attr_icon = ICON.get(type(self._device)) - self._attr_state_class = STATE_CLASS.get(type(self._device)) - self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT.get( - type(self._device) - ) + if entity_description := ENTITY_DESCRIPTIONS.get(type(device)): + self.entity_description = entity_description @callback def async_update_callback(self, force_update=False): @@ -214,16 +238,13 @@ class DeconzTemperature(DeconzDevice, SensorEntity): Extra temperature sensor on certain Xiaomi devices. """ - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = TEMP_CELSIUS - TYPE = DOMAIN def __init__(self, device, gateway): """Initialize deCONZ temperature sensor.""" super().__init__(device, gateway) + self.entity_description = ENTITY_DESCRIPTIONS[Temperature] self._attr_name = f"{self._device.name} Temperature" @property @@ -247,16 +268,13 @@ class DeconzTemperature(DeconzDevice, SensorEntity): class DeconzBattery(DeconzDevice, SensorEntity): """Battery class for when a device is only represented as an event.""" - _attr_device_class = DEVICE_CLASS_BATTERY - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = PERCENTAGE - TYPE = DOMAIN def __init__(self, device, gateway): """Initialize deCONZ battery level sensor.""" super().__init__(device, gateway) + self.entity_description = ENTITY_DESCRIPTIONS[Battery] self._attr_name = f"{self._device.name} Battery Level" @callback @@ -273,7 +291,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): Normally there should only be one battery sensor per device from deCONZ. With specific Danfoss devices each endpoint can report its own battery state. """ - if self._device.manufacturer == "Danfoss" and self._device.modelid in [ + if self._device.manufacturer == "Danfoss" and self._device.model_id in [ "0x8030", "0x8031", "0x8034", @@ -292,7 +310,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): """Return the state attributes of the battery.""" attr = {} - if self._device.type in Switch.ZHATYPE: + if isinstance(self._device, Switch): for event in self.gateway.events: if self._device == event.device: attr[ATTR_EVENT_ID] = event.event_id diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 08ee9e11561..361ab1715c0 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -185,7 +185,7 @@ async def async_remove_orphaned_entries_service(gateway): # Don't remove the Gateway service entry gateway_service = device_registry.async_get_device( - identifiers={(DOMAIN, gateway.api.config.bridgeid)}, connections=set() + identifiers={(DOMAIN, gateway.api.config.bridge_id)}, connections=set() ) if gateway_service.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_service.id) diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py new file mode 100644 index 00000000000..9138bb3ac14 --- /dev/null +++ b/homeassistant/components/deconz/siren.py @@ -0,0 +1,78 @@ +"""Support for deCONZ siren.""" + +from pydeconz.light import Siren + +from homeassistant.components.siren import ( + ATTR_DURATION, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SirenEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import NEW_LIGHT +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up sirens for deCONZ component.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_siren(lights=gateway.api.lights.values()): + """Add siren from deCONZ.""" + entities = [] + + for light in lights: + + if ( + isinstance(light, Siren) + and light.unique_id not in gateway.entities[DOMAIN] + ): + entities.append(DeconzSiren(light, gateway)) + + if entities: + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_siren + ) + ) + + async_add_siren() + + +class DeconzSiren(DeconzDevice, SirenEntity): + """Representation of a deCONZ siren.""" + + TYPE = DOMAIN + + def __init__(self, device, gateway) -> None: + """Set up siren.""" + super().__init__(device, gateway) + + self._attr_supported_features = ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_DURATION + ) + + @property + def is_on(self): + """Return true if siren is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + """Turn on siren.""" + data = {} + if (duration := kwargs.get(ATTR_DURATION)) is not None: + data["duration"] = duration * 10 + await self._device.turn_on(**data) + + async def async_turn_off(self, **kwargs): + """Turn off siren.""" + await self._device.turn_off() diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 492872ecca0..f7559e37838 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -1,9 +1,12 @@ """Support for deCONZ switches.""" + +from pydeconz.light import Siren + from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import NEW_LIGHT, POWER_PLUGS, SIRENS +from .const import DOMAIN as DECONZ_DOMAIN, NEW_LIGHT, POWER_PLUGS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -16,6 +19,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Siren platform replacing sirens in switch platform added in 2021.10 + for light in gateway.api.lights.values(): + if isinstance(light, Siren) and ( + entity_id := entity_registry.async_get_entity_id( + DOMAIN, DECONZ_DOMAIN, light.unique_id + ) + ): + entity_registry.async_remove(entity_id) + @callback def async_add_switch(lights=gateway.api.lights.values()): """Add switch from deCONZ.""" @@ -25,15 +39,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if ( light.type in POWER_PLUGS - and light.uniqueid not in gateway.entities[DOMAIN] + and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzPowerPlug(light, gateway)) - elif ( - light.type in SIRENS and light.uniqueid not in gateway.entities[DOMAIN] - ): - entities.append(DeconzSiren(light, gateway)) - if entities: async_add_entities(entities) @@ -58,29 +67,8 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): async def async_turn_on(self, **kwargs): """Turn on switch.""" - data = {"on": True} - await self._device.async_set_state(data) + await self._device.set_state(on=True) async def async_turn_off(self, **kwargs): """Turn off switch.""" - data = {"on": False} - await self._device.async_set_state(data) - - -class DeconzSiren(DeconzDevice, SwitchEntity): - """Representation of a deCONZ siren.""" - - TYPE = DOMAIN - - @property - def is_on(self): - """Return true if switch is on.""" - return self._device.is_on - - async def async_turn_on(self, **kwargs): - """Turn on switch.""" - await self._device.turn_on() - - async def async_turn_off(self, **kwargs): - """Turn off switch.""" - await self._device.turn_off() + await self._device.set_state(on=False) diff --git a/homeassistant/components/deconz/translations/es.json b/homeassistant/components/deconz/translations/es.json index 3670caf18d0..5608b95288d 100644 --- a/homeassistant/components/deconz/translations/es.json +++ b/homeassistant/components/deconz/translations/es.json @@ -81,7 +81,7 @@ "remote_flip_180_degrees": "Dispositivo volteado 180 grados", "remote_flip_90_degrees": "Dispositivo volteado 90 grados", "remote_gyro_activated": "Dispositivo sacudido", - "remote_moved": "Dispositivo movido con \"{subtipo}\" hacia arriba", + "remote_moved": "Dispositivo movido con \"{subtype}\" hacia arriba", "remote_moved_any_side": "Dispositivo movido con cualquier lado hacia arriba", "remote_rotate_from_side_1": "Dispositivo girado del \"lado 1\" al \" {subtype} \"", "remote_rotate_from_side_2": "Dispositivo girado del \"lado 2\" al \" {subtype} \"", diff --git a/homeassistant/components/deconz/translations/fr.json b/homeassistant/components/deconz/translations/fr.json index 05d53405e54..48322a55659 100644 --- a/homeassistant/components/deconz/translations/fr.json +++ b/homeassistant/components/deconz/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", "no_hardware_available": "Aucun mat\u00e9riel radio connect\u00e9 \u00e0 deCONZ", "not_deconz_bridge": "Pas un pont deCONZ", @@ -23,7 +23,7 @@ }, "manual_input": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" } }, diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index bc003a279e8..a78a6ef1961 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_bridges": "Nem tal\u00e1lhat\u00f3 deCONZ \u00e1tj\u00e1r\u00f3", "no_hardware_available": "Nincs deCONZ-hoz csatlakoztatott r\u00e1di\u00f3hardver", "not_deconz_bridge": "Nem egy deCONZ \u00e1tj\u00e1r\u00f3", "updated_instance": "A deCONZ-p\u00e9ld\u00e1ny \u00faj \u00e1llom\u00e1sc\u00edmmel friss\u00edtve" @@ -11,19 +11,19 @@ "error": { "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" }, - "flow_title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 ({host})", + "flow_title": "{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" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot, hogy csatlakozzon {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", + "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" }, "link": { - "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot", + "description": "Enged\u00e9lyezze fel a deCONZ \u00e1tj\u00e1r\u00f3ban a Home Assistanthoz val\u00f3 regisztr\u00e1l\u00e1st.\n\n1. V\u00e1lassza ki a deCONZ rendszer be\u00e1ll\u00edt\u00e1sait\n2. Nyomja meg az \"Authenticate app\" gombot", "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" }, "manual_input": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } }, @@ -71,7 +71,7 @@ "remote_button_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak", "remote_button_rotated": "A gomb elforgatva: \"{subtype}\"", "remote_button_rotated_fast": "A gomb gyorsan elfordult: \"{subtype}\"", - "remote_button_rotation_stopped": "A (z) \"{subtype}\" gomb forg\u00e1sa le\u00e1llt", + "remote_button_rotation_stopped": "A(z) \"{subtype}\" gomb forg\u00e1sa le\u00e1llt", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", "remote_button_short_release": "\"{subtype}\" gomb elengedve", "remote_button_triple_press": "\"{subtype}\" gombra h\u00e1romszor kattintottak", diff --git a/homeassistant/components/deconz/translations/id.json b/homeassistant/components/deconz/translations/id.json index c6d54beaec2..f63261e6e87 100644 --- a/homeassistant/components/deconz/translations/id.json +++ b/homeassistant/components/deconz/translations/id.json @@ -11,11 +11,11 @@ "error": { "no_key": "Tidak bisa mendapatkan kunci API" }, - "flow_title": "Gateway Zigbee deCONZ ({host})", + "flow_title": "{host}", "step": { "hassio_confirm": { - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke gateway deCONZ yang disediakan oleh add-on Supervisor {addon}?", - "title": "Gateway Zigbee deCONZ melalui add-on Supervisor" + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke gateway deCONZ yang disediakan oleh add-on: {addon}?", + "title": "Gateway Zigbee deCONZ melalui add-on Home Assistant" }, "link": { "description": "Buka gateway deCONZ Anda untuk mendaftarkan ke Home Assistant. \n\n1. Buka pengaturan sistem deCONZ \n2. Tekan tombol \"Authenticate app\"", diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index d5bb71da67b..5a4485c7365 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -10,6 +10,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, ) @@ -42,6 +43,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, + STATE_ALARM_ARMED_VACATION: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, STATE_ALARM_DISARMED: { CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), diff --git a/homeassistant/components/demo/translations/he.json b/homeassistant/components/demo/translations/he.json new file mode 100644 index 00000000000..c3162b87a5e --- /dev/null +++ b/homeassistant/components/demo/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05d4\u05d3\u05d2\u05de\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json index 3bfe095189a..e77c21294b8 100644 --- a/homeassistant/components/demo/translations/hu.json +++ b/homeassistant/components/demo/translations/hu.json @@ -17,7 +17,7 @@ "options_2": { "data": { "multi": "T\u00f6bbsz\u00f6r\u00f6s kijel\u00f6l\u00e9s", - "select": "V\u00e1lassz egy lehet\u0151s\u00e9get", + "select": "V\u00e1lasszon egy lehet\u0151s\u00e9get", "string": "Karakterl\u00e1nc \u00e9rt\u00e9k" } } diff --git a/homeassistant/components/demo/translations/ro.json b/homeassistant/components/demo/translations/ro.json new file mode 100644 index 00000000000..96e182c6d54 --- /dev/null +++ b/homeassistant/components/demo/translations/ro.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "few": "Cateva", + "one": "Unu", + "other": "Altele" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.pl.json b/homeassistant/components/demo/translations/select.pl.json index e90b2ccd0cb..276095d21fb 100644 --- a/homeassistant/components/demo/translations/select.pl.json +++ b/homeassistant/components/demo/translations/select.pl.json @@ -1,9 +1,9 @@ { "state": { "demo__speed": { - "light_speed": "Pr\u0119dko\u015b\u0107 \u015bwiat\u0142a", - "ludicrous_speed": "Absurdalna pr\u0119dko\u015b\u0107", - "ridiculous_speed": "Niewiarygodna pr\u0119dko\u015b\u0107" + "light_speed": "pr\u0119dko\u015b\u0107 \u015bwiat\u0142a", + "ludicrous_speed": "absurdalna pr\u0119dko\u015b\u0107", + "ridiculous_speed": "niewiarygodna pr\u0119dko\u015b\u0107" } } } \ No newline at end of file diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index c684c8b0dc5..ce79d937264 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -3,7 +3,7 @@ "name": "Denon AVR Network Receivers", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.10.8"], + "requirements": ["denonavr==0.10.9"], "codeowners": ["@scarface-4711", "@starkillerOG"], "ssdp": [ { diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index caa34e352d0..68a5b8c71d8 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -309,10 +309,7 @@ class DenonDevice(MediaPlayerEntity): @property def media_content_type(self): """Content type of current playing media.""" - if ( - self._receiver.state == STATE_PLAYING - or self._receiver.state == STATE_PAUSED - ): + if self._receiver.state in (STATE_PLAYING, STATE_PAUSED): return MEDIA_TYPE_MUSIC return MEDIA_TYPE_CHANNEL diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index a35b6c80fcd..5c15468e6d4 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -1,8 +1,8 @@ """Code to handle a DenonAVR receiver.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Callable from denonavr import DenonAVR diff --git a/homeassistant/components/denonavr/translations/fr.json b/homeassistant/components/denonavr/translations/fr.json index 797f10fe06f..0b7fb29a6d8 100644 --- a/homeassistant/components/denonavr/translations/fr.json +++ b/homeassistant/components/denonavr/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour ce Denon AVR est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de la connexion, veuillez r\u00e9essayer, d\u00e9brancher l'alimentation secteur et les c\u00e2bles ethernet et les reconnecter peut aider", "not_denonavr_manufacturer": "Ce n'est pas un r\u00e9cepteur r\u00e9seau Denon AVR, le fabricant d\u00e9couvert ne correspondait pas", "not_denonavr_missing": "Ce n'est pas un r\u00e9cepteur r\u00e9seau Denon AVR, les informations d\u00e9couvertes ne sont pas compl\u00e8tes" diff --git a/homeassistant/components/denonavr/translations/hu.json b/homeassistant/components/denonavr/translations/hu.json index 43ee362d65a..c22d392dc8a 100644 --- a/homeassistant/components/denonavr/translations/hu.json +++ b/homeassistant/components/denonavr/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Nem siker\u00fclt csatlakozni, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra. A h\u00e1l\u00f3zati \u00e9s Ethernet k\u00e1belek kih\u00faz\u00e1sa \u00e9s \u00fajracsatlakoztat\u00e1sa seg\u00edthet", "not_denonavr_manufacturer": "Nem egy Denon AVR h\u00e1l\u00f3zati vev\u0151, felfedezett gy\u00e1rt\u00f3 nem egyezik", "not_denonavr_missing": "Nem Denon AVR h\u00e1l\u00f3zati vev\u0151, a felfedez\u00e9si inform\u00e1ci\u00f3k nem teljesek" diff --git a/homeassistant/components/denonavr/translations/id.json b/homeassistant/components/denonavr/translations/id.json index d78f547ef35..0bafe289842 100644 --- a/homeassistant/components/denonavr/translations/id.json +++ b/homeassistant/components/denonavr/translations/id.json @@ -10,7 +10,7 @@ "error": { "discovery_error": "Gagal menemukan Network Receiver AVR Denon" }, - "flow_title": "Network Receiver Denon AVR: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Konfirmasikan penambahan Receiver", diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 89a3f8f6408..567e579d8b8 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Iterable, Mapping from functools import wraps import logging from types import ModuleType -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol import voluptuous_serialize @@ -36,19 +36,31 @@ DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( } ) + +class DeviceAutomationDetails(NamedTuple): + """Details for device automation.""" + + section: str + get_automations_func: str + get_capabilities_func: str + + TYPES = { - # platform name, get automations function, get capabilities function - "trigger": ( + "trigger": DeviceAutomationDetails( "device_trigger", "async_get_triggers", "async_get_trigger_capabilities", ), - "condition": ( + "condition": DeviceAutomationDetails( "device_condition", "async_get_conditions", "async_get_condition_capabilities", ), - "action": ("device_action", "async_get_actions", "async_get_action_capabilities"), + "action": DeviceAutomationDetails( + "device_action", + "async_get_actions", + "async_get_action_capabilities", + ), } @@ -92,7 +104,7 @@ async def async_get_device_automation_platform( Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation. """ - platform_name = TYPES[automation_type][0] + platform_name = TYPES[automation_type].section try: integration = await async_get_integration_with_requirements(hass, domain) platform = integration.get_platform(platform_name) @@ -119,7 +131,7 @@ async def _async_get_device_automations_from_domain( except InvalidDeviceAutomationConfig: return {} - function_name = TYPES[automation_type][1] + function_name = TYPES[automation_type].get_automations_func return await asyncio.gather( *( @@ -196,7 +208,7 @@ async def _async_get_device_automation_capabilities(hass, automation_type, autom except InvalidDeviceAutomationConfig: return {} - function_name = TYPES[automation_type][2] + function_name = TYPES[automation_type].get_capabilities_func if not hasattr(platform, function_name): # The device automation has no capabilities diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 2e9576ee74a..5d08f8d9d31 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation.const import ( CONF_IS_OFF, CONF_IS_ON, @@ -146,7 +149,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 0b8fd6da7f4..49a52fa887e 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any, Final import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.zone import DOMAIN as DOMAIN_ZONE, trigger as zone from homeassistant.const import ( @@ -72,7 +75,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "enters": diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 991a4bb7bb1..31d060200f0 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Sequence +from collections.abc import Callable, Coroutine, Sequence from datetime import timedelta import hashlib from types import ModuleType -from typing import Any, Callable, Final, final +from typing import Any, Final, final import attr import voluptuous as vol diff --git a/homeassistant/components/device_tracker/translations/he.json b/homeassistant/components/device_tracker/translations/he.json index 2f3ccc1ec1e..e20a3291008 100644 --- a/homeassistant/components/device_tracker/translations/he.json +++ b/homeassistant/components/device_tracker/translations/he.json @@ -1,8 +1,18 @@ { + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u05d1\u05d1\u05d9\u05ea", + "is_not_home": "{entity_name} \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + }, + "trigger_type": { + "enters": "{entity_name} \u05e0\u05db\u05e0\u05e1 \u05dc\u05d0\u05d6\u05d5\u05e8", + "leaves": "{entity_name} \u05d9\u05e6\u05d0 \u05de\u05d0\u05d6\u05d5\u05e8" + } + }, "state": { "_": { "home": "\u05d1\u05d1\u05d9\u05ea", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + "not_home": "\u05d1\u05d7\u05d5\u05e5" } }, "title": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" diff --git a/homeassistant/components/devolo_home_control/subscriber.py b/homeassistant/components/devolo_home_control/subscriber.py index 9899aa3a587..13ffabeaba2 100644 --- a/homeassistant/components/devolo_home_control/subscriber.py +++ b/homeassistant/components/devolo_home_control/subscriber.py @@ -1,7 +1,6 @@ """Subscriber for devolo home control API publisher.""" - +from collections.abc import Callable import logging -from typing import Callable _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/devolo_home_control/translations/ca.json b/homeassistant/components/devolo_home_control/translations/ca.json index 968624e15c8..73417c5c564 100644 --- a/homeassistant/components/devolo_home_control/translations/ca.json +++ b/homeassistant/components/devolo_home_control/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/devolo_home_control/translations/fr.json b/homeassistant/components/devolo_home_control/translations/fr.json index bc9a5715238..020b469092d 100644 --- a/homeassistant/components/devolo_home_control/translations/fr.json +++ b/homeassistant/components/devolo_home_control/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide", @@ -13,14 +13,14 @@ "data": { "mydevolo_url": "URL mydevolo", "password": "Mot de passe", - "username": "Adresse e-mail / devolo ID" + "username": "Email / devolo ID" } }, "zeroconf_confirm": { "data": { "mydevolo_url": "mydevolo URL", "password": "Mot de passe", - "username": "[%key:common::config_flow::d ata::email%] / devolo ID" + "username": "Email / devolo ID" } } } diff --git a/homeassistant/components/devolo_home_control/translations/id.json b/homeassistant/components/devolo_home_control/translations/id.json index 31f0f87dc00..41d2100b6ed 100644 --- a/homeassistant/components/devolo_home_control/translations/id.json +++ b/homeassistant/components/devolo_home_control/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": { "invalid_auth": "Autentikasi tidak valid" @@ -13,6 +14,13 @@ "password": "Kata Sandi", "username": "Email/ID devolo" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "URL mydevolo", + "password": "Kata Sandi", + "username": "Email/devolo ID" + } } } } diff --git a/homeassistant/components/devolo_home_control/translations/ko.json b/homeassistant/components/devolo_home_control/translations/ko.json index 9c9a21182cc..f3832dec7c1 100644 --- a/homeassistant/components/devolo_home_control/translations/ko.json +++ b/homeassistant/components/devolo_home_control/translations/ko.json @@ -13,6 +13,13 @@ "password": "\ube44\ubc00\ubc88\ud638", "username": "\uc774\uba54\uc77c / devolo ID" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c / devolo ID" + } } } } diff --git a/homeassistant/components/dexcom/translations/ca.json b/homeassistant/components/dexcom/translations/ca.json index e188718a71d..7b97a209e49 100644 --- a/homeassistant/components/dexcom/translations/ca.json +++ b/homeassistant/components/dexcom/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 1a49667bad8..61208ac6423 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -14,13 +14,8 @@ from aiodiscover.discovery import ( IP_ADDRESS as DISCOVERY_IP_ADDRESS, MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) -from scapy.arch.common import compile_filter from scapy.config import conf from scapy.error import Scapy_Exception -from scapy.layers.dhcp import DHCP -from scapy.layers.inet import IP -from scapy.layers.l2 import Ether -from scapy.sendrecv import AsyncSniffer from homeassistant.components.device_tracker.const import ( ATTR_HOST_NAME, @@ -282,6 +277,44 @@ class DHCPWatcher(WatcherBase): async def async_start(self): """Start watching for dhcp packets.""" + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 + arch, + ) + from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel + from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel + from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel + + # + # Importing scapy.sendrecv will cause a scapy resync which will + # import scapy.arch.read_routes which will import scapy.sendrecv + # + # We avoid this circular import by importing arch above to ensure + # the module is loaded and avoid the problem + # + from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel + AsyncSniffer, + ) + + def _handle_dhcp_packet(packet): + """Process a dhcp packet.""" + if DHCP not in packet: + return + + options = packet[DHCP].options + request_type = _decode_dhcp_option(options, MESSAGE_TYPE) + if request_type != DHCP_REQUEST: + # Not a DHCP request + return + + ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src + hostname = _decode_dhcp_option(options, HOSTNAME) or "" + mac_address = _format_mac(packet[Ether].src) + + if ip_address is not None and mac_address is not None: + self.process_client(ip_address, hostname, mac_address) + # disable scapy promiscuous mode as we do not need it conf.sniff_promisc = 0 @@ -308,7 +341,7 @@ class DHCPWatcher(WatcherBase): self._sniffer = AsyncSniffer( filter=FILTER, started_callback=self._started.set, - prn=self.handle_dhcp_packet, + prn=_handle_dhcp_packet, store=0, ) @@ -316,27 +349,6 @@ class DHCPWatcher(WatcherBase): if self._sniffer.thread: self._sniffer.thread.name = self.__class__.__name__ - def handle_dhcp_packet(self, packet): - """Process a dhcp packet.""" - if DHCP not in packet: - return - - options = packet[DHCP].options - - request_type = _decode_dhcp_option(options, MESSAGE_TYPE) - if request_type != DHCP_REQUEST: - # DHCP request - return - - ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src - hostname = _decode_dhcp_option(options, HOSTNAME) or "" - mac_address = _format_mac(packet[Ether].src) - - if ip_address is None or mac_address is None: - return - - self.process_client(ip_address, hostname, mac_address) - def create_task(self, task): """Pass a task to hass.add_job since we are in a thread.""" return self.hass.add_job(task) @@ -382,4 +394,10 @@ def _verify_working_pcap(cap_filter): If we cannot create a filter we will be listening for all traffic which is too intensive. """ + # Local import because importing from scapy has side effects such as opening + # sockets + from scapy.arch.common import ( # pylint: disable=import-outside-toplevel + compile_filter, + ) + compile_filter(cap_filter) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 3cf03c09d3f..8ec6bf855c8 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -2,7 +2,7 @@ "domain": "dhcp", "name": "DHCP Discovery", "documentation": "https://www.home-assistant.io/integrations/dhcp", - "requirements": ["scapy==2.4.5", "aiodiscover==1.4.2"], + "requirements": ["scapy==2.4.5", "aiodiscover==1.4.4"], "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/dialogflow/translations/hu.json b/homeassistant/components/dialogflow/translations/hu.json index 17f38b0262f..23a6001d77c 100644 --- a/homeassistant/components/dialogflow/translations/hu.json +++ b/homeassistant/components/dialogflow/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Dialogflow webhook integr\u00e1ci\u00f3j\u00e1t]({dialogflow_url}). \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Dialogflowt?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Dialogflowt?", "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py index 853386fd1d8..b840b7bd2dc 100644 --- a/homeassistant/components/directv/const.py +++ b/homeassistant/components/directv/const.py @@ -1,4 +1,5 @@ """Constants for the DirecTV integration.""" +from typing import Final DOMAIN = "directv" @@ -7,7 +8,7 @@ ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_RATING = "media_rating" ATTR_MEDIA_RECORDED = "media_recorded" ATTR_MEDIA_START_TIME = "media_start_time" -ATTR_VIA_DEVICE = "via_device" +ATTR_VIA_DEVICE: Final = "via_device" CONF_RECEIVER_ID = "receiver_id" diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 1a7d07c5ebd..0f2dcabd552 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -67,7 +67,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Set up the DirecTV config entry.""" dtv = hass.data[DOMAIN][entry.entry_id] entities = [] @@ -98,7 +98,6 @@ class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): self._attr_name = name self._attr_device_class = DEVICE_CLASS_RECEIVER self._attr_available = False - self._attr_assumed_state = None self._is_recorded = None self._is_standby = True diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 52e94bc2608..c8c84a7f0cc 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -25,7 +25,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Load DirecTV remote based on a config entry.""" dtv = hass.data[DOMAIN][entry.entry_id] entities = [] diff --git a/homeassistant/components/directv/translations/fr.json b/homeassistant/components/directv/translations/fr.json index 4876c455ff0..6d5bc24cb5a 100644 --- a/homeassistant/components/directv/translations/fr.json +++ b/homeassistant/components/directv/translations/fr.json @@ -18,7 +18,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" } } } diff --git a/homeassistant/components/directv/translations/hu.json b/homeassistant/components/directv/translations/hu.json index 3e0a7d5cb57..9e3aa3efb13 100644 --- a/homeassistant/components/directv/translations/hu.json +++ b/homeassistant/components/directv/translations/hu.json @@ -14,11 +14,11 @@ "one": "\u00dcres", "other": "\u00dcres" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/directv/translations/id.json b/homeassistant/components/directv/translations/id.json index 74f778d6cee..fcf7318d906 100644 --- a/homeassistant/components/directv/translations/id.json +++ b/homeassistant/components/directv/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "DirecTV: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Ingin menyiapkan {name}?" diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 3d90956a2b5..a751f437cc1 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -1,4 +1,6 @@ """Show the amount of records in a user's Discogs collection.""" +from __future__ import annotations + from datetime import timedelta import logging import random @@ -6,7 +8,11 @@ import random import discogs_client 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, @@ -34,30 +40,33 @@ SENSOR_COLLECTION_TYPE = "collection" SENSOR_WANTLIST_TYPE = "wantlist" SENSOR_RANDOM_RECORD_TYPE = "random_record" -SENSORS = { - SENSOR_COLLECTION_TYPE: { - "name": "Collection", - "icon": ICON_RECORD, - "unit_of_measurement": UNIT_RECORDS, - }, - SENSOR_WANTLIST_TYPE: { - "name": "Wantlist", - "icon": ICON_RECORD, - "unit_of_measurement": UNIT_RECORDS, - }, - SENSOR_RANDOM_RECORD_TYPE: { - "name": "Random Record", - "icon": ICON_PLAYER, - "unit_of_measurement": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_COLLECTION_TYPE, + name="Collection", + icon=ICON_RECORD, + native_unit_of_measurement=UNIT_RECORDS, + ), + SensorEntityDescription( + key=SENSOR_WANTLIST_TYPE, + name="Wantlist", + icon=ICON_RECORD, + native_unit_of_measurement=UNIT_RECORDS, + ), + SensorEntityDescription( + key=SENSOR_RANDOM_RECORD_TYPE, + name="Random Record", + icon=ICON_PLAYER, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_TOKEN): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -81,51 +90,37 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("API token is not valid") return - sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(DiscogsSensor(discogs_data, name, sensor_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + DiscogsSensor(discogs_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(sensors, True) + add_entities(entities, True) class DiscogsSensor(SensorEntity): """Create a new Discogs sensor for a specific type.""" - def __init__(self, discogs_data, name, sensor_type): + def __init__(self, discogs_data, name, description: SensorEntityDescription): """Initialize the Discogs sensor.""" + self.entity_description = description self._discogs_data = discogs_data - self._name = name - self._type = sensor_type - self._state = None - self._attrs = {} + self._attrs: dict = {} - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSORS[self._type]['name']}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return SENSORS[self._type]["icon"] - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return SENSORS[self._type]["unit_of_measurement"] + self._attr_name = f"{name} {description.name}" @property def extra_state_attributes(self): """Return the device state attributes of the sensor.""" - if self._state is None or self._attrs is None: + if self._attr_native_value is None or self._attrs is None: return None - if self._type == SENSOR_RANDOM_RECORD_TYPE and self._state is not None: + if ( + self.entity_description.key == SENSOR_RANDOM_RECORD_TYPE + and self._attr_native_value is not None + ): return { "cat_no": self._attrs["labels"][0]["catno"], "cover_image": self._attrs["cover_image"], @@ -156,9 +151,9 @@ class DiscogsSensor(SensorEntity): def update(self): """Set state to the amount of records in user's collection.""" - if self._type == SENSOR_COLLECTION_TYPE: - self._state = self._discogs_data["collection_count"] - elif self._type == SENSOR_WANTLIST_TYPE: - self._state = self._discogs_data["wantlist_count"] + if self.entity_description.key == SENSOR_COLLECTION_TYPE: + self._attr_native_value = self._discogs_data["collection_count"] + elif self.entity_description.key == SENSOR_WANTLIST_TYPE: + self._attr_native_value = self._discogs_data["wantlist_count"] else: - self._state = self.get_random_record() + self._attr_native_value = self.get_random_record() diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index c475c502f60..0da186e7924 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -2,7 +2,7 @@ "domain": "discord", "name": "Discord", "documentation": "https://www.home-assistant.io/integrations/discord", - "requirements": ["discord.py==1.7.2"], + "requirements": ["discord.py==1.7.3"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 8bf31a94aef..bade569bb46 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -1,7 +1,10 @@ """Starts a service to scan in intervals for new devices.""" +from __future__ import annotations + from datetime import timedelta import json import logging +from typing import NamedTuple from netdisco.discovery import NetworkDiscovery import voluptuous as vol @@ -44,20 +47,27 @@ CONFIG_ENTRY_HANDLERS = { "logitech_mediaserver": "squeezebox", } + +class ServiceDetails(NamedTuple): + """Store service details.""" + + component: str + platform: str | None + + # These have no config flows SERVICE_HANDLERS = { - SERVICE_NETGEAR: ("device_tracker", None), - SERVICE_ENIGMA2: ("media_player", "enigma2"), - SERVICE_SABNZBD: ("sabnzbd", None), - "yamaha": ("media_player", "yamaha"), - "frontier_silicon": ("media_player", "frontier_silicon"), - "openhome": ("media_player", "openhome"), - "bose_soundtouch": ("media_player", "soundtouch"), - "bluesound": ("media_player", "bluesound"), - "lg_smart_device": ("media_player", "lg_soundbar"), + SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), + SERVICE_SABNZBD: ServiceDetails("sabnzbd", None), + "yamaha": ServiceDetails("media_player", "yamaha"), + "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), + "openhome": ServiceDetails("media_player", "openhome"), + "bose_soundtouch": ServiceDetails("media_player", "soundtouch"), + "bluesound": ServiceDetails("media_player", "bluesound"), + "lg_smart_device": ServiceDetails("media_player", "lg_soundbar"), } -OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} +OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} MIGRATED_SERVICE_HANDLERS = [ SERVICE_APPLE_TV, @@ -65,6 +75,7 @@ MIGRATED_SERVICE_HANDLERS = [ "deconz", SERVICE_DAIKIN, "denonavr", + SERVICE_DLNA_DMR, "esphome", "google_cast", SERVICE_HASS_IOS_APP, @@ -76,6 +87,7 @@ MIGRATED_SERVICE_HANDLERS = [ "kodi", SERVICE_KONNECTED, SERVICE_MOBILE_APP, + SERVICE_NETGEAR, SERVICE_OCTOPRINT, "philips_hue", SERVICE_SAMSUNG_PRINTER, @@ -169,24 +181,24 @@ async def async_setup(hass, config): ) return - comp_plat = SERVICE_HANDLERS.get(service) + service_details = SERVICE_HANDLERS.get(service) - if not comp_plat and service in enabled_platforms: - comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + if not service_details and service in enabled_platforms: + service_details = OPTIONAL_SERVICE_HANDLERS[service] # We do not know how to handle this service. - if not comp_plat: + if not service_details: logger.debug("Unknown service discovered: %s %s", service, info) return logger.info("Found new service: %s %s", service, info) - component, platform = comp_plat - - if platform is None: - await async_discover(hass, service, info, component, config) + if service_details.platform is None: + await async_discover(hass, service, info, service_details.component, config) else: - await async_load_platform(hass, component, platform, info, config) + await async_load_platform( + hass, service_details.component, service_details.platform, info, config + ) async def scan_devices(now): """Scan for devices.""" diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json index 558c727c62c..1b7d51c1716 100644 --- a/homeassistant/components/discovery/manifest.json +++ b/homeassistant/components/discovery/manifest.json @@ -2,7 +2,7 @@ "domain": "discovery", "name": "Discovery", "documentation": "https://www.home-assistant.io/integrations/discovery", - "requirements": ["netdisco==2.9.0"], + "requirements": ["netdisco==3.0.0"], "after_dependencies": ["zeroconf"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py index f38456ec6ee..536567336fd 100644 --- a/homeassistant/components/dlna_dmr/__init__.py +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -1 +1,56 @@ """The dlna_dmr component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import CONF_PLATFORM, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, LOGGER + +PLATFORMS = [MEDIA_PLAYER_DOMAIN] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up DLNA component.""" + if MEDIA_PLAYER_DOMAIN not in config: + return True + + for entry_config in config[MEDIA_PLAYER_DOMAIN]: + if entry_config.get(CONF_PLATFORM) != DOMAIN: + continue + LOGGER.warning( + "Configuring dlna_dmr via yaml is deprecated; the configuration for" + " %s has been migrated to a config entry and can be safely removed", + entry_config.get(CONF_URL), + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_config, + ) + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up a DLNA DMR device from a config entry.""" + LOGGER.debug("Setting up config entry: %s", entry.unique_id) + + # Forward setup to the appropriate platform + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + # Forward to the same platform as async_setup_entry did + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py new file mode 100644 index 00000000000..53513d593f5 --- /dev/null +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -0,0 +1,340 @@ +"""Config flow for DLNA DMR.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from pprint import pformat +from typing import Any, Mapping, Optional +from urllib.parse import urlparse + +from async_upnp_client.client import UpnpError +from async_upnp_client.profiles.dlna import DmrDevice +from async_upnp_client.profiles.profile import find_device_of_type +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_TYPE, CONF_URL +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import IntegrationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN, +) +from .data import get_domain_data + +LOGGER = logging.getLogger(__name__) + +FlowInput = Optional[Mapping[str, Any]] + + +class ConnectError(IntegrationError): + """Error occurred when trying to connect to a device.""" + + +class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a DLNA DMR config flow. + + The Unique Device Name (UDN) of the DMR device is used as the unique_id for + config entries and for entities. This UDN may differ from the root UDN if + the DMR is an embedded device. + """ + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._discoveries: list[Mapping[str, str]] = [] + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Define the config flow to handle options.""" + return DlnaDmrOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: FlowInput = None) -> FlowResult: + """Handle a flow initialized by the user: manual URL entry. + + Discovered devices will already be displayed, no need to prompt user + with them here. + """ + LOGGER.debug("async_step_user: user_input: %s", user_input) + + errors = {} + if user_input is not None: + try: + discovery = await self._async_connect(user_input[CONF_URL]) + except ConnectError as err: + errors["base"] = err.args[0] + else: + # If unmigrated config was imported earlier then use it + import_data = get_domain_data(self.hass).unmigrated_config.get( + user_input[CONF_URL] + ) + if import_data is not None: + return await self.async_step_import(import_data) + # Device setup manually, assume we don't get SSDP broadcast notifications + options = {CONF_POLL_AVAILABILITY: True} + return await self._async_create_entry_from_discovery(discovery, options) + + data_schema = vol.Schema({CONF_URL: str}) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, import_data: FlowInput = None) -> FlowResult: + """Import a new DLNA DMR device from a config entry. + + This flow is triggered by `async_setup`. If no device has been + configured before, find any matching device and create a config_entry + for it. Otherwise, do nothing. + """ + LOGGER.debug("async_step_import: import_data: %s", import_data) + + if not import_data or CONF_URL not in import_data: + LOGGER.debug("Entry not imported: incomplete_config") + return self.async_abort(reason="incomplete_config") + + self._async_abort_entries_match({CONF_URL: import_data[CONF_URL]}) + + location = import_data[CONF_URL] + self._discoveries = await self._async_get_discoveries() + + poll_availability = True + + # Find the device in the list of unconfigured devices + for discovery in self._discoveries: + if discovery[ssdp.ATTR_SSDP_LOCATION] == location: + # Device found via SSDP, it shouldn't need polling + poll_availability = False + LOGGER.debug( + "Entry %s found via SSDP, with UDN %s", + import_data[CONF_URL], + discovery[ssdp.ATTR_SSDP_UDN], + ) + break + else: + # Not in discoveries. Try connecting directly. + try: + discovery = await self._async_connect(location) + except ConnectError as err: + LOGGER.debug( + "Entry %s not imported: %s", import_data[CONF_URL], err.args[0] + ) + # Store the config to apply if the device is added later + get_domain_data(self.hass).unmigrated_config[location] = import_data + return self.async_abort(reason=err.args[0]) + + # Set options from the import_data, except listen_ip which is no longer used + options = { + CONF_LISTEN_PORT: import_data.get(CONF_LISTEN_PORT), + CONF_CALLBACK_URL_OVERRIDE: import_data.get(CONF_CALLBACK_URL_OVERRIDE), + CONF_POLL_AVAILABILITY: poll_availability, + } + + # Override device name if it's set in the YAML + if CONF_NAME in import_data: + discovery = dict(discovery) + discovery[ssdp.ATTR_UPNP_FRIENDLY_NAME] = import_data[CONF_NAME] + + LOGGER.debug("Entry %s ready for import", import_data[CONF_URL]) + return await self._async_create_entry_from_discovery(discovery, options) + + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle a flow initialized by SSDP discovery.""" + LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) + + self._discoveries = [discovery_info] + + udn = discovery_info[ssdp.ATTR_SSDP_UDN] + location = discovery_info[ssdp.ATTR_SSDP_LOCATION] + + # Abort if already configured, but update the last-known location + await self.async_set_unique_id(udn) + self._abort_if_unique_id_configured( + updates={CONF_URL: location}, reload_on_update=False + ) + + # If the device needs migration because it wasn't turned on when HA + # started, silently migrate it now. + import_data = get_domain_data(self.hass).unmigrated_config.get(location) + if import_data is not None: + return await self.async_step_import(import_data) + + parsed_url = urlparse(location) + name = discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname + self.context["title_placeholders"] = {"name": name} + + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input: FlowInput = None) -> FlowResult: + """Allow the user to confirm adding the device. + + Also check that the device is still available, otherwise when it is + added to HA it won't report the correct DeviceInfo. + """ + LOGGER.debug("async_step_confirm: %s", user_input) + + errors = {} + if user_input is not None: + discovery = self._discoveries[0] + try: + await self._async_connect(discovery[ssdp.ATTR_SSDP_LOCATION]) + except ConnectError as err: + errors["base"] = err.args[0] + else: + return await self._async_create_entry_from_discovery(discovery) + + self._set_confirm_only() + return self.async_show_form(step_id="confirm", errors=errors) + + async def _async_create_entry_from_discovery( + self, + discovery: Mapping[str, Any], + options: Mapping[str, Any] | None = None, + ) -> FlowResult: + """Create an entry from discovery.""" + LOGGER.debug("_async_create_entry_from_discovery: discovery: %s", discovery) + + location = discovery[ssdp.ATTR_SSDP_LOCATION] + udn = discovery[ssdp.ATTR_SSDP_UDN] + + # Abort if already configured, but update the last-known location + await self.async_set_unique_id(udn) + self._abort_if_unique_id_configured(updates={CONF_URL: location}) + + parsed_url = urlparse(location) + title = discovery.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname + + data = { + CONF_URL: discovery[ssdp.ATTR_SSDP_LOCATION], + CONF_DEVICE_ID: discovery[ssdp.ATTR_SSDP_UDN], + CONF_TYPE: discovery.get(ssdp.ATTR_SSDP_NT) or discovery[ssdp.ATTR_SSDP_ST], + } + return self.async_create_entry(title=title, data=data, options=options) + + async def _async_get_discoveries(self) -> list[Mapping[str, str]]: + """Get list of unconfigured DLNA devices discovered by SSDP.""" + LOGGER.debug("_get_discoveries") + + # Get all compatible devices from ssdp's cache + discoveries: list[Mapping[str, str]] = [] + for udn_st in DmrDevice.DEVICE_TYPES: + st_discoveries = await ssdp.async_get_discovery_info_by_st( + self.hass, udn_st + ) + discoveries.extend(st_discoveries) + + # Filter out devices already configured + current_unique_ids = { + entry.unique_id for entry in self._async_current_entries() + } + discoveries = [ + disc + for disc in discoveries + if disc[ssdp.ATTR_SSDP_UDN] not in current_unique_ids + ] + + return discoveries + + async def _async_connect(self, location: str) -> dict[str, str]: + """Connect to a device to confirm it works and get discovery information. + + Raises ConnectError if something goes wrong. + """ + LOGGER.debug("_async_connect(location=%s)", location) + domain_data = get_domain_data(self.hass) + try: + device = await domain_data.upnp_factory.async_create_device(location) + except UpnpError as err: + raise ConnectError("could_not_connect") from err + + try: + device = find_device_of_type(device, DmrDevice.DEVICE_TYPES) + except UpnpError as err: + raise ConnectError("not_dmr") from err + + discovery = { + ssdp.ATTR_SSDP_LOCATION: location, + ssdp.ATTR_SSDP_UDN: device.udn, + ssdp.ATTR_SSDP_ST: device.device_type, + ssdp.ATTR_UPNP_FRIENDLY_NAME: device.name, + } + + return discovery + + +class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a DLNA DMR options flow. + + Configures the single instance and updates the existing config entry. + """ + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + # Don't modify existing (read-only) options -- copy and update instead + options = dict(self.config_entry.options) + + if user_input is not None: + LOGGER.debug("user_input: %s", user_input) + listen_port = user_input.get(CONF_LISTEN_PORT) or None + callback_url_override = user_input.get(CONF_CALLBACK_URL_OVERRIDE) or None + + try: + # Cannot use cv.url validation in the schema itself so apply + # extra validation here + if callback_url_override: + cv.url(callback_url_override) + except vol.Invalid: + errors["base"] = "invalid_url" + + options[CONF_LISTEN_PORT] = listen_port + options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override + options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY] + + # Save if there's no errors, else fall through and show the form again + if not errors: + return self.async_create_entry(title="", data=options) + + fields = {} + + def _add_with_suggestion(key: str, validator: Callable) -> None: + """Add a field to with a suggested, not default, value.""" + suggested_value = options.get(key) + if suggested_value is None: + fields[vol.Optional(key)] = validator + else: + fields[ + vol.Optional(key, description={"suggested_value": suggested_value}) + ] = validator + + # listen_port can be blank or 0 for "bind any free port" + _add_with_suggestion(CONF_LISTEN_PORT, cv.port) + _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str) + fields[ + vol.Required( + CONF_POLL_AVAILABILITY, + default=options.get(CONF_POLL_AVAILABILITY, False), + ) + ] = bool + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(fields), + errors=errors, + ) diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py new file mode 100644 index 00000000000..f3217fdafff --- /dev/null +++ b/homeassistant/components/dlna_dmr/const.py @@ -0,0 +1,60 @@ +"""Constants for the DLNA DMR component.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Final + +from homeassistant.components.media_player import const as _mp_const + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "dlna_dmr" + +CONF_LISTEN_PORT: Final = "listen_port" +CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" +CONF_POLL_AVAILABILITY: Final = "poll_availability" + +DEFAULT_NAME: Final = "DLNA Digital Media Renderer" + +CONNECT_TIMEOUT: Final = 10 + +# Map UPnP class to media_player media_content_type +MEDIA_TYPE_MAP: Mapping[str, str] = { + "object": _mp_const.MEDIA_TYPE_URL, + "object.item": _mp_const.MEDIA_TYPE_URL, + "object.item.imageItem": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.imageItem.photo": _mp_const.MEDIA_TYPE_IMAGE, + "object.item.audioItem": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.musicTrack": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBroadcast": _mp_const.MEDIA_TYPE_MUSIC, + "object.item.audioItem.audioBook": _mp_const.MEDIA_TYPE_PODCAST, + "object.item.videoItem": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.videoItem.movie": _mp_const.MEDIA_TYPE_MOVIE, + "object.item.videoItem.videoBroadcast": _mp_const.MEDIA_TYPE_TVSHOW, + "object.item.videoItem.musicVideoClip": _mp_const.MEDIA_TYPE_VIDEO, + "object.item.playlistItem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.item.textItem": _mp_const.MEDIA_TYPE_URL, + "object.item.bookmarkItem": _mp_const.MEDIA_TYPE_URL, + "object.item.epgItem": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.audioProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.item.epgItem.videoProgram": _mp_const.MEDIA_TYPE_EPISODE, + "object.container": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.person": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.person.musicArtist": _mp_const.MEDIA_TYPE_ARTIST, + "object.container.playlistContainer": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.album": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.musicAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.album.photoAlbum": _mp_const.MEDIA_TYPE_ALBUM, + "object.container.genre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.musicGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.genre.movieGenre": _mp_const.MEDIA_TYPE_GENRE, + "object.container.channelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.audioChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.channelGroup.videoChannelGroup": _mp_const.MEDIA_TYPE_CHANNELS, + "object.container.epgContainer": _mp_const.MEDIA_TYPE_TVSHOW, + "object.container.storageSystem": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageVolume": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST, + "object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST, +} diff --git a/homeassistant/components/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py new file mode 100644 index 00000000000..8d4693dd435 --- /dev/null +++ b/homeassistant/components/dlna_dmr/data.py @@ -0,0 +1,126 @@ +"""Data used by this integration.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, NamedTuple, cast + +from async_upnp_client import UpnpEventHandler, UpnpFactory, UpnpRequester +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN, LOGGER + + +class EventListenAddr(NamedTuple): + """Unique identifier for an event listener.""" + + host: str | None # Specific local IP(v6) address for listening on + port: int # Listening port, 0 means use an ephemeral port + callback_url: str | None + + +class DlnaDmrData: + """Storage class for domain global data.""" + + lock: asyncio.Lock + requester: UpnpRequester + upnp_factory: UpnpFactory + event_notifiers: dict[EventListenAddr, AiohttpNotifyServer] + event_notifier_refs: defaultdict[EventListenAddr, int] + stop_listener_remove: CALLBACK_TYPE | None = None + unmigrated_config: dict[str, Mapping[str, Any]] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global data.""" + self.lock = asyncio.Lock() + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + self.requester = AiohttpSessionRequester(session, with_sleep=False) + self.upnp_factory = UpnpFactory(self.requester, non_strict=True) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + self.unmigrated_config = {} + + async def async_cleanup_event_notifiers(self, event: Event) -> None: + """Clean up resources when Home Assistant is stopped.""" + del event # unused + LOGGER.debug("Cleaning resources in DlnaDmrData") + async with self.lock: + tasks = (server.stop_server() for server in self.event_notifiers.values()) + asyncio.gather(*tasks) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + + async def async_get_event_notifier( + self, listen_addr: EventListenAddr, hass: HomeAssistant + ) -> UpnpEventHandler: + """Return existing event notifier for the listen_addr, or create one. + + Only one event notify server is kept for each listen_addr. Must call + async_release_event_notifier when done to cleanup resources. + """ + LOGGER.debug("Getting event handler for %s", listen_addr) + + async with self.lock: + # Stop all servers when HA shuts down, to release resources on devices + if not self.stop_listener_remove: + self.stop_listener_remove = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_cleanup_event_notifiers + ) + + # Always increment the reference counter, for existing or new event handlers + self.event_notifier_refs[listen_addr] += 1 + + # Return an existing event handler if we can + if listen_addr in self.event_notifiers: + return self.event_notifiers[listen_addr].event_handler + + # Start event handler + server = AiohttpNotifyServer( + requester=self.requester, + listen_port=listen_addr.port, + listen_host=listen_addr.host, + callback_url=listen_addr.callback_url, + loop=hass.loop, + ) + await server.start_server() + LOGGER.debug("Started event handler at %s", server.callback_url) + + self.event_notifiers[listen_addr] = server + + return server.event_handler + + async def async_release_event_notifier(self, listen_addr: EventListenAddr) -> None: + """Indicate that the event notifier for listen_addr is not used anymore. + + This is called once by each caller of async_get_event_notifier, and will + stop the listening server when all users are done. + """ + async with self.lock: + assert self.event_notifier_refs[listen_addr] > 0 + self.event_notifier_refs[listen_addr] -= 1 + + # Shutdown the server when it has no more users + if self.event_notifier_refs[listen_addr] == 0: + server = self.event_notifiers.pop(listen_addr) + await server.stop_server() + + # Remove the cleanup listener when there's nothing left to cleanup + if not self.event_notifiers: + assert self.stop_listener_remove is not None + self.stop_listener_remove() + self.stop_listener_remove = None + + +def get_domain_data(hass: HomeAssistant) -> DlnaDmrData: + """Obtain this integration's domain data, creating it if needed.""" + if DOMAIN in hass.data: + return cast(DlnaDmrData, hass.data[DOMAIN]) + + data = DlnaDmrData(hass) + hass.data[DOMAIN] = data + return data diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 67d9713628a..53bee3d8519 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -1,9 +1,10 @@ { "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.20.0"], - "dependencies": ["network"], - "codeowners": [], + "requirements": ["async-upnp-client==0.22.5"], + "dependencies": ["network", "ssdp"], + "codeowners": ["@StevenLooman", "@chishm"], "iot_class": "local_push" } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 36f62155b2d..8542464e41e 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -2,16 +2,19 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta import functools -import logging +from typing import Any, Callable, TypeVar, cast -import aiohttp -from async_upnp_client import UpnpFactory -from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester -from async_upnp_client.profiles.dlna import DeviceState, DmrDevice +from async_upnp_client import UpnpError, UpnpService, UpnpStateVariable +from async_upnp_client.const import NotificationSubType +from async_upnp_client.profiles.dlna import DmrDevice, TransportState +from async_upnp_client.utils import async_get_local_ip import voluptuous as vol +from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, @@ -24,12 +27,11 @@ 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_DEVICE_ID, CONF_NAME, + CONF_TYPE, CONF_URL, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, @@ -37,285 +39,520 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DEFAULT_NAME, + DOMAIN, + LOGGER as _LOGGER, + MEDIA_TYPE_MAP, +) +from .data import EventListenAddr, get_domain_data -DLNA_DMR_DATA = "dlna_dmr" - -DEFAULT_NAME = "DLNA Digital Media Renderer" -DEFAULT_LISTEN_PORT = 8301 +PARALLEL_UPDATES = 0 +# Configuration via YAML is deprecated in favour of config flow CONF_LISTEN_IP = "listen_ip" -CONF_LISTEN_PORT = "listen_port" -CONF_CALLBACK_URL_OVERRIDE = "callback_url_override" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_LISTEN_IP): cv.string, - vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_URL), + cv.deprecated(CONF_LISTEN_IP), + cv.deprecated(CONF_LISTEN_PORT), + cv.deprecated(CONF_NAME), + cv.deprecated(CONF_CALLBACK_URL_OVERRIDE), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, + } + ), ) - -def catch_request_errors(): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - - def call_wrapper(func): - """Call wrapper for decorator.""" - - @functools.wraps(func) - async def wrapper(self, *args, **kwargs): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - try: - return await func(self, *args, **kwargs) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error during call %s", func.__name__) - - return wrapper - - return call_wrapper +Func = TypeVar("Func", bound=Callable[..., Any]) -async def async_start_event_handler( +def catch_request_errors(func: Func) -> Func: + """Catch UpnpError errors.""" + + @functools.wraps(func) + async def wrapper(self: "DlnaDmrEntity", *args: Any, **kwargs: Any) -> Any: + """Catch UpnpError errors and check availability before and after request.""" + if not self.available: + _LOGGER.warning( + "Device disappeared when trying to call service %s", func.__name__ + ) + return + try: + return await func(self, *args, **kwargs) + except UpnpError as err: + self.check_available = True + _LOGGER.error("Error during call %s: %r", func.__name__, err) + + return cast(Func, wrapper) + + +async def async_setup_entry( hass: HomeAssistant, - server_host: str, - server_port: int, - requester, - callback_url_override: str | None = None, -): - """Register notify view.""" - hass_data = hass.data[DLNA_DMR_DATA] - if "event_handler" in hass_data: - return hass_data["event_handler"] + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DlnaDmrEntity from a config entry.""" + del hass # Unused + _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) - # start event handler - server = AiohttpNotifyServer( - requester, - listen_port=server_port, - listen_host=server_host, - callback_url=callback_url_override, + # Create our own device-wrapping entity + entity = DlnaDmrEntity( + udn=entry.data[CONF_DEVICE_ID], + device_type=entry.data[CONF_TYPE], + name=entry.title, + event_port=entry.options.get(CONF_LISTEN_PORT) or 0, + event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), + poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), + location=entry.data[CONF_URL], ) - await server.start_server() - _LOGGER.info("UPNP/DLNA event handler listening, url: %s", server.callback_url) - hass_data["notify_server"] = server - hass_data["event_handler"] = server.event_handler - # register for graceful shutdown - async def async_stop_server(event): - """Stop server.""" - _LOGGER.debug("Stopping UPNP/DLNA event handler") - await server.stop_server() + entry.async_on_unload( + entry.add_update_listener(entity.async_config_update_listener) + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) - - return hass_data["event_handler"] + async_add_entities([entity]) -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up DLNA DMR platform.""" - if config.get(CONF_URL) is not None: - url = config[CONF_URL] - name = config.get(CONF_NAME) - elif discovery_info is not None: - url = discovery_info["ssdp_description"] - name = discovery_info.get("name") +class DlnaDmrEntity(MediaPlayerEntity): + """Representation of a DLNA DMR device as a HA entity.""" - if DLNA_DMR_DATA not in hass.data: - hass.data[DLNA_DMR_DATA] = {} + udn: str + device_type: str - if "lock" not in hass.data[DLNA_DMR_DATA]: - hass.data[DLNA_DMR_DATA]["lock"] = asyncio.Lock() + _event_addr: EventListenAddr + poll_availability: bool + # Last known URL for the device, used when adding this entity to hass to try + # to connect before SSDP has rediscovered it, or when SSDP discovery fails. + location: str - # build upnp/aiohttp requester - session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True) + _device_lock: asyncio.Lock # Held when connecting or disconnecting the device + _device: DmrDevice | None = None + _remove_ssdp_callbacks: list[Callable] + check_available: bool = False - # ensure event handler has been started - async with hass.data[DLNA_DMR_DATA]["lock"]: - server_host = config.get(CONF_LISTEN_IP) - if server_host is None: - 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( - hass, server_host, server_port, requester, callback_url_override + # Track BOOTID in SSDP advertisements for device changes + _bootid: int | None = None + + # DMR devices need polling for track position information. async_update will + # determine whether further device polling is required. + _attr_should_poll = True + + def __init__( + self, + udn: str, + device_type: str, + name: str, + event_port: int, + event_callback_url: str | None, + poll_availability: bool, + location: str, + ) -> None: + """Initialize DLNA DMR entity.""" + self.udn = udn + self.device_type = device_type + self._attr_name = name + self._event_addr = EventListenAddr(None, event_port, event_callback_url) + self.poll_availability = poll_availability + self.location = location + self._device_lock = asyncio.Lock() + self._remove_ssdp_callbacks = [] + + async def async_added_to_hass(self) -> None: + """Handle addition.""" + # Try to connect to the last known location, but don't worry if not available + if not self._device: + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.debug("Couldn't connect immediately: %r", err) + + # Get SSDP notifications for only this device + self._remove_ssdp_callbacks.append( + await ssdp.async_register_callback( + self.hass, self.async_ssdp_callback, {"USN": self.usn} + ) ) - # create upnp device - factory = UpnpFactory(requester, non_strict=True) - try: - upnp_device = await factory.async_create_device(url) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - raise PlatformNotReady() from err + # async_upnp_client.SsdpListener only reports byebye once for each *UDN* + # (device name) which often is not the USN (service within the device) + # that we're interested in. So also listen for byebye advertisements for + # the UDN, which is reported in the _udn field of the combined_headers. + self._remove_ssdp_callbacks.append( + await ssdp.async_register_callback( + self.hass, + self.async_ssdp_callback, + {"_udn": self.udn, "NTS": NotificationSubType.SSDP_BYEBYE}, + ) + ) - # wrap with DmrDevice - dlna_device = DmrDevice(upnp_device, event_handler) + async def async_will_remove_from_hass(self) -> None: + """Handle removal.""" + for callback in self._remove_ssdp_callbacks: + callback() + self._remove_ssdp_callbacks.clear() + await self._device_disconnect() - # create our own device - device = DlnaDmrDevice(dlna_device, name) - _LOGGER.debug("Adding device: %s", device) - async_add_entities([device], True) - - -class DlnaDmrDevice(MediaPlayerEntity): - """Representation of a DLNA DMR device.""" - - def __init__(self, dmr_device, name=None): - """Initialize DLNA DMR device.""" - self._device = dmr_device - self._name = name - - self._available = False - self._subscription_renew_time = None - - async def async_added_to_hass(self): - """Handle addition.""" - self._device.on_event = self._on_event - - # Register unsubscribe on stop - bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) - - @property - def available(self): - """Device is available.""" - return self._available - - async def _async_on_hass_stop(self, event): - """Event handler on Home Assistant stop.""" - async with self.hass.data[DLNA_DMR_DATA]["lock"]: - await self._device.async_unsubscribe_services() - - async def async_update(self): - """Retrieve the latest data.""" - was_available = self._available + async def async_ssdp_callback( + self, info: Mapping[str, Any], change: ssdp.SsdpChange + ) -> None: + """Handle notification from SSDP of device state change.""" + _LOGGER.debug( + "SSDP %s notification of device %s at %s", + change, + info[ssdp.ATTR_SSDP_USN], + info.get(ssdp.ATTR_SSDP_LOCATION), + ) try: - await self._device.async_update() - self._available = True - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Device unavailable") + bootid_str = info[ssdp.ATTR_SSDP_BOOTID] + bootid: int | None = int(bootid_str, 10) + except (KeyError, ValueError): + bootid = None + + if change == ssdp.SsdpChange.UPDATE: + # This is an announcement that bootid is about to change + if self._bootid is not None and self._bootid == bootid: + # Store the new value (because our old value matches) so that we + # can ignore subsequent ssdp:alive messages + try: + next_bootid_str = info[ssdp.ATTR_SSDP_NEXTBOOTID] + self._bootid = int(next_bootid_str, 10) + except (KeyError, ValueError): + pass + # Nothing left to do until ssdp:alive comes through return - # do we need to (re-)subscribe? - now = dt_util.utcnow() - should_renew = ( - self._subscription_renew_time and now >= self._subscription_renew_time - ) - if should_renew or not was_available and self._available: - try: - timeout = await self._device.async_subscribe_services() - self._subscription_renew_time = dt_util.utcnow() + timeout / 2 - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Could not (re)subscribe") + if self._bootid is not None and self._bootid != bootid and self._device: + # Device has rebooted, drop existing connection and maybe reconnect + await self._device_disconnect() + self._bootid = bootid - def _on_event(self, service, state_variables): + if change == ssdp.SsdpChange.BYEBYE and self._device: + # Device is going away, disconnect + await self._device_disconnect() + + if change == ssdp.SsdpChange.ALIVE and not self._device: + location = info[ssdp.ATTR_SSDP_LOCATION] + try: + await self._device_connect(location) + except UpnpError as err: + _LOGGER.warning( + "Failed connecting to recently alive device at %s: %r", + location, + err, + ) + + # Device could have been de/re-connected, state probably changed + self.schedule_update_ha_state() + + async def async_config_update_listener( + self, hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Handle options update by modifying self in-place.""" + del hass # Unused + _LOGGER.debug( + "Updating: %s with data=%s and options=%s", + self.name, + entry.data, + entry.options, + ) + self.location = entry.data[CONF_URL] + self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) + + new_port = entry.options.get(CONF_LISTEN_PORT) or 0 + new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) + + if ( + new_port == self._event_addr.port + and new_callback_url == self._event_addr.callback_url + ): + return + + # Changes to eventing requires a device reconnect for it to update correctly + await self._device_disconnect() + # Update _event_addr after disconnecting, to stop the right event listener + self._event_addr = self._event_addr._replace( + port=new_port, callback_url=new_callback_url + ) + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.warning("Couldn't (re)connect after config change: %r", err) + + # Device was de/re-connected, state might have changed + self.schedule_update_ha_state() + + async def _device_connect(self, location: str) -> None: + """Connect to the device now that it's available.""" + _LOGGER.debug("Connecting to device at %s", location) + + async with self._device_lock: + if self._device: + _LOGGER.debug("Trying to connect when device already connected") + return + + domain_data = get_domain_data(self.hass) + + # Connect to the base UPNP device + upnp_device = await domain_data.upnp_factory.async_create_device(location) + + # Create/get event handler that is reachable by the device, using + # the connection's local IP to listen only on the relevant interface + _, event_ip = await async_get_local_ip(location, self.hass.loop) + self._event_addr = self._event_addr._replace(host=event_ip) + event_handler = await domain_data.async_get_event_notifier( + self._event_addr, self.hass + ) + + # Create profile wrapper + self._device = DmrDevice(upnp_device, event_handler) + + self.location = location + + # Subscribe to event notifications + try: + self._device.on_event = self._on_event + await self._device.async_subscribe_services(auto_resubscribe=True) + except UpnpError as err: + # Don't leave the device half-constructed + self._device.on_event = None + self._device = None + await domain_data.async_release_event_notifier(self._event_addr) + _LOGGER.debug("Error while subscribing during device connect: %r", err) + raise + + if ( + not self.registry_entry + or not self.registry_entry.config_entry_id + or self.registry_entry.device_id + ): + return + + # Create linked HA DeviceEntry now the information is known. + dev_reg = device_registry.async_get(self.hass) + device_entry = dev_reg.async_get_or_create( + config_entry_id=self.registry_entry.config_entry_id, + # Connections are based on the root device's UDN, and the DMR + # embedded device's UDN. They may be the same, if the DMR is the + # root device. + connections={ + ( + device_registry.CONNECTION_UPNP, + self._device.profile_device.root_device.udn, + ), + (device_registry.CONNECTION_UPNP, self._device.udn), + }, + identifiers={(DOMAIN, self.unique_id)}, + default_manufacturer=self._device.manufacturer, + default_model=self._device.model_name, + default_name=self._device.name, + ) + + # Update entity registry to link to the device + ent_reg = entity_registry.async_get(self.hass) + ent_reg.async_get_or_create( + self.registry_entry.domain, + self.registry_entry.platform, + self.unique_id, + device_id=device_entry.id, + ) + + async def _device_disconnect(self) -> None: + """Destroy connections to the device now that it's not available. + + Also call when removing this entity from hass to clean up connections. + """ + async with self._device_lock: + if not self._device: + _LOGGER.debug("Disconnecting from device that's not connected") + return + + _LOGGER.debug("Disconnecting from %s", self._device.name) + + self._device.on_event = None + old_device = self._device + self._device = None + await old_device.async_unsubscribe_services() + + domain_data = get_domain_data(self.hass) + await domain_data.async_release_event_notifier(self._event_addr) + + async def async_update(self) -> None: + """Retrieve the latest data.""" + if not self._device: + if not self.poll_availability: + return + try: + await self._device_connect(self.location) + except UpnpError: + return + + assert self._device is not None + + try: + do_ping = self.poll_availability or self.check_available + await self._device.async_update(do_ping=do_ping) + except UpnpError: + _LOGGER.debug("Device unavailable") + await self._device_disconnect() + return + finally: + self.check_available = False + + def _on_event( + self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] + ) -> None: """State variable(s) changed, let home-assistant know.""" + del service # Unused + if not state_variables: + # Indicates a failure to resubscribe, check if device is still available + self.check_available = True self.schedule_update_ha_state() @property - def supported_features(self): - """Flag media player features that are supported.""" + def available(self) -> bool: + """Device is available when we have a connection to it.""" + return self._device is not None and self._device.profile_device.available + + @property + def unique_id(self) -> str: + """Report the UDN (Unique Device Name) as this entity's unique ID.""" + return self.udn + + @property + def usn(self) -> str: + """Get the USN based on the UDN (Unique Device Name) and device type.""" + return f"{self.udn}::{self.device_type}" + + @property + def state(self) -> str | None: + """State of the player.""" + if not self._device or not self.available: + return STATE_OFF + if self._device.transport_state is None: + return STATE_ON + if self._device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): + return STATE_PLAYING + if self._device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): + return STATE_PAUSED + if self._device.transport_state == TransportState.VENDOR_DEFINED: + # Unable to map this state to anything reasonable, so it's "Unknown" + return None + + return STATE_IDLE + + @property + def supported_features(self) -> int: + """Flag media player features that are supported at this moment. + + Supported features may change as the device enters different states. + """ + if not self._device: + return 0 + supported_features = 0 if self._device.has_volume_level: supported_features |= SUPPORT_VOLUME_SET if self._device.has_volume_mute: supported_features |= SUPPORT_VOLUME_MUTE - if self._device.has_play: + if self._device.can_play: supported_features |= SUPPORT_PLAY - if self._device.has_pause: + if self._device.can_pause: supported_features |= SUPPORT_PAUSE - if self._device.has_stop: + if self._device.can_stop: supported_features |= SUPPORT_STOP - if self._device.has_previous: + if self._device.can_previous: supported_features |= SUPPORT_PREVIOUS_TRACK - if self._device.has_next: + if self._device.can_next: supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: supported_features |= SUPPORT_PLAY_MEDIA - if self._device.has_seek_rel_time: + if self._device.can_seek_rel_time: supported_features |= SUPPORT_SEEK return supported_features @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._device.has_volume_level: - return self._device.volume_level - return 0 + if not self._device or not self._device.has_volume_level: + return None + return self._device.volume_level - @catch_request_errors() - async def async_set_volume_level(self, volume): + @catch_request_errors + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" + assert self._device is not None await self._device.async_set_volume_level(volume) @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" + if not self._device: + return None return self._device.is_volume_muted - @catch_request_errors() - async def async_mute_volume(self, mute): + @catch_request_errors + async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" + assert self._device is not None desired_mute = bool(mute) await self._device.async_mute_volume(desired_mute) - @catch_request_errors() - async def async_media_pause(self): + @catch_request_errors + async def async_media_pause(self) -> None: """Send pause command.""" - if not self._device.can_pause: - _LOGGER.debug("Cannot do Pause") - return - + assert self._device is not None await self._device.async_pause() - @catch_request_errors() - async def async_media_play(self): + @catch_request_errors + async def async_media_play(self) -> None: """Send play command.""" - if not self._device.can_play: - _LOGGER.debug("Cannot do Play") - return - + assert self._device is not None await self._device.async_play() - @catch_request_errors() - async def async_media_stop(self): + @catch_request_errors + async def async_media_stop(self) -> None: """Send stop command.""" - if not self._device.can_stop: - _LOGGER.debug("Cannot do Stop") - return - + assert self._device is not None await self._device.async_stop() - @catch_request_errors() - async def async_media_seek(self, position): + @catch_request_errors + async def async_media_seek(self, position: int | float) -> None: """Send seek command.""" - if not self._device.can_seek_rel_time: - _LOGGER.debug("Cannot do Seek/rel_time") - return - + assert self._device is not None time = timedelta(seconds=position) await self._device.async_seek_rel_time(time) - @catch_request_errors() - async def async_play_media(self, media_type, media_id, **kwargs): + @catch_request_errors + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) title = "Home Assistant" + assert self._device is not None + # Stop current playing media if self._device.can_stop: await self.async_media_stop() @@ -325,81 +562,152 @@ class DlnaDmrDevice(MediaPlayerEntity): await self._device.async_wait_for_can_play() # If already playing, no need to call Play - if self._device.state == DeviceState.PLAYING: + if self._device.transport_state == TransportState.PLAYING: return # Play it await self.async_media_play() - @catch_request_errors() - async def async_media_previous_track(self): + @catch_request_errors + async def async_media_previous_track(self) -> None: """Send previous track command.""" - if not self._device.can_previous: - _LOGGER.debug("Cannot do Previous") - return - + assert self._device is not None await self._device.async_previous() - @catch_request_errors() - async def async_media_next_track(self): + @catch_request_errors + async def async_media_next_track(self) -> None: """Send next track command.""" - if not self._device.can_next: - _LOGGER.debug("Cannot do Next") - return - + assert self._device is not None await self._device.async_next() @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" - return self._device.media_title + if not self._device: + return None + # Use the best available title + return self._device.media_program_title or self._device.media_title @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" + if not self._device: + return None return self._device.media_image_url @property - def state(self): - """State of the player.""" - if not self._available: - return STATE_OFF - - if self._device.state is None: - return STATE_ON - if self._device.state == DeviceState.PLAYING: - return STATE_PLAYING - if self._device.state == DeviceState.PAUSED: - return STATE_PAUSED - - return STATE_IDLE + def media_content_id(self) -> str | None: + """Content ID of current playing media.""" + if not self._device: + return None + return self._device.current_track_uri @property - def media_duration(self): + def media_content_type(self) -> str | None: + """Content type of current playing media.""" + if not self._device or not self._device.media_class: + return None + return MEDIA_TYPE_MAP.get(self._device.media_class) + + @property + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" + if not self._device: + return None return self._device.media_duration @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" + if not self._device: + return None return self._device.media_position @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid. Returns value from homeassistant.util.dt.utcnow(). """ + if not self._device: + return None return self._device.media_position_updated_at @property - def name(self) -> str: - """Return the name of the device.""" - if self._name: - return self._name - return self._device.name + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_artist @property - def unique_id(self) -> str: - """Return an unique ID.""" - return self._device.udn + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_name + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_album_artist + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if not self._device: + return None + return self._device.media_track_number + + @property + def media_series_title(self) -> str | None: + """Title of series of current playing media, TV show only.""" + if not self._device: + return None + return self._device.media_series_title + + @property + def media_season(self) -> str | None: + """Season number, starting at 1, of current playing media, TV show only.""" + if not self._device: + return None + # Some DMRs, like Kodi, leave this as 0 and encode the season & episode + # in the episode_number metadata, as {season:d}{episode:02d} + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + try: + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode // 100) + except ValueError: + pass + return self._device.media_season_number + + @property + def media_episode(self) -> str | None: + """Episode number of current playing media, TV show only.""" + if not self._device: + return None + # Complement to media_season math above + if ( + not self._device.media_season_number + or self._device.media_season_number == "0" + ) and self._device.media_episode_number: + try: + episode = int(self._device.media_episode_number, 10) + if episode > 100: + return str(episode % 100) + except ValueError: + pass + return self._device.media_episode_number + + @property + def media_channel(self) -> str | None: + """Channel name currently playing.""" + if not self._device: + return None + return self._device.media_channel_name diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json new file mode 100644 index 00000000000..27e96b465db --- /dev/null +++ b/homeassistant/components/dlna_dmr/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "DLNA Digital Media Renderer", + "description": "URL to a device description XML file", + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "could_not_connect": "Failed to connect to DLNA device", + "discovery_error": "Failed to discover a matching DLNA device", + "incomplete_config": "Configuration is missing a required variable", + "non_unique_id": "Multiple devices found with the same unique ID", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "error": { + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a Digital Media Renderer" + } + }, + "options": { + "step": { + "init": { + "title": "DLNA Digital Media Renderer configuration", + "data": { + "listen_port": "Event listener port (random if not set)", + "callback_url_override": "Event listener callback URL", + "poll_availability": "Poll for device availability" + } + } + }, + "error": { + "invalid_url": "Invalid URL" + } + } +} diff --git a/homeassistant/components/dlna_dmr/translations/ca.json b/homeassistant/components/dlna_dmr/translations/ca.json new file mode 100644 index 00000000000..cf3adc94405 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ca.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "could_not_connect": "No s'ha pogut connectar amb el dispositiu DLNA", + "discovery_error": "No s'ha pogut descobrir cap dispositiu DLNA coincident", + "incomplete_config": "Falta una variable obligat\u00f2ria a la configuraci\u00f3", + "non_unique_id": "S'han trobat diversos dispositius amb el mateix identificador \u00fanic", + "not_dmr": "El dispositiu no \u00e9s un renderitzador de mitjans digitals" + }, + "error": { + "could_not_connect": "No s'ha pogut connectar amb el dispositiu DLNA", + "not_dmr": "El dispositiu no \u00e9s un renderitzador de mitjans digitals" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL al fitxer XML de descripci\u00f3 de dispositiu", + "title": "Renderitzador de mitjans digitals DLNA" + } + } + }, + "options": { + "error": { + "invalid_url": "URL inv\u00e0lid" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL de crida de l'oient d'esdeveniments", + "listen_port": "Port de l'oient d'esdeveniments (aleatori si no es defineix)", + "poll_availability": "Sondeja per saber la disponibilitat del dispositiu" + }, + "title": "Configuraci\u00f3 del renderitzador de mitjans digitals DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/de.json b/homeassistant/components/dlna_dmr/translations/de.json new file mode 100644 index 00000000000..50f66761748 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/de.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "could_not_connect": "Verbindung zum DLNA-Ger\u00e4t fehlgeschlagen", + "discovery_error": "Ein passendes DLNA-Ger\u00e4t konnte nicht gefunden werden", + "incomplete_config": "In der Konfiguration fehlt eine erforderliche Variable", + "non_unique_id": "Mehrere Ger\u00e4te mit derselben eindeutigen ID gefunden", + "not_dmr": "Ger\u00e4t ist kein Digital Media Renderer" + }, + "error": { + "could_not_connect": "Verbindung zum DLNA-Ger\u00e4t fehlgeschlagen", + "not_dmr": "Ger\u00e4t ist kein Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL zu einer XML-Datei mit Ger\u00e4tebeschreibung", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Ung\u00fcltige URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "R\u00fcckruf-URL des Ereignis-Listeners", + "listen_port": "Port des Ereignis-Listeners (zuf\u00e4llig, wenn nicht festgelegt)", + "poll_availability": "Abfrage der Ger\u00e4teverf\u00fcgbarkeit" + }, + "title": "DLNA Digital Media Renderer Konfiguration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/en.json b/homeassistant/components/dlna_dmr/translations/en.json new file mode 100644 index 00000000000..94bbd365e18 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "could_not_connect": "Failed to connect to DLNA device", + "discovery_error": "Failed to discover a matching DLNA device", + "incomplete_config": "Configuration is missing a required variable", + "non_unique_id": "Multiple devices found with the same unique ID", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "error": { + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to start set up?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL to a device description XML file", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Invalid URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Event listener port (random if not set)", + "poll_availability": "Poll for device availability" + }, + "title": "DLNA Digital Media Renderer configuration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/et.json b/homeassistant/components/dlna_dmr/translations/et.json new file mode 100644 index 00000000000..e32101ab251 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/et.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "could_not_connect": "DLNA seadmega \u00fchenduse loomine nurjus", + "discovery_error": "Sobiva DLNA -seadme leidmine nurjus", + "incomplete_config": "Seadetes puudub n\u00f5utav muutuja", + "non_unique_id": "Leiti mitu sama unikaalse ID-ga seadet", + "not_dmr": "Seade ei ole digitaalse meedia renderdaja" + }, + "error": { + "could_not_connect": "DLNA seadmega \u00fchenduse loomine nurjus", + "not_dmr": "Seade ei ole digitaalse meedia renderdaja" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kas alustada seadistamist?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL aadress seadme kirjelduse XML-failile", + "title": "DLNA digitaalse meediumi renderdaja" + } + } + }, + "options": { + "error": { + "invalid_url": "Sobimatu URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "S\u00fcndmuse kuulaja URL", + "listen_port": "S\u00fcndmuste kuulaja port (juhuslik kui pole m\u00e4\u00e4ratud)", + "poll_availability": "K\u00fcsitle seadme saadavuse kohta" + }, + "title": "DLNA digitaalse meediumi renderdaja s\u00e4tted" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/he.json b/homeassistant/components/dlna_dmr/translations/he.json new file mode 100644 index 00000000000..fbdaa0403f4 --- /dev/null +++ b/homeassistant/components/dlna_dmr/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" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + }, + "user": { + "data": { + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/hu.json b/homeassistant/components/dlna_dmr/translations/hu.json new file mode 100644 index 00000000000..faa7e73eb76 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/hu.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van konfigur\u00e1lva", + "could_not_connect": "Nem siker\u00fclt csatlakozni a DLNA-eszk\u00f6zh\u00f6z", + "discovery_error": "Nem siker\u00fclt megfelel\u0151 DLNA-eszk\u00f6zt tal\u00e1lni", + "incomplete_config": "A konfigur\u00e1ci\u00f3b\u00f3l hi\u00e1nyzik egy sz\u00fcks\u00e9ges \u00e9rt\u00e9k", + "non_unique_id": "T\u00f6bb eszk\u00f6z tal\u00e1lhat\u00f3 ugyanazzal az egyedi azonos\u00edt\u00f3val", + "not_dmr": "Az eszk\u00f6z nem digit\u00e1lis m\u00e9dia renderel\u0151" + }, + "error": { + "could_not_connect": "Nem siker\u00fclt csatlakozni a DLNA-eszk\u00f6zh\u00f6z", + "not_dmr": "Az eszk\u00f6z nem digit\u00e1lis m\u00e9dia renderel\u0151" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kezd\u0151dhet a be\u00e1ll\u00edt\u00e1s?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "Az eszk\u00f6z le\u00edr\u00e1s\u00e1nak XML-f\u00e1jl URL-c\u00edme", + "title": "DLNA digit\u00e1lis m\u00e9dia renderel\u0151" + } + } + }, + "options": { + "error": { + "invalid_url": "\u00c9rv\u00e9nytelen URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Esem\u00e9nyfigyel\u0151 visszah\u00edv\u00e1si URL (callback)", + "listen_port": "Esem\u00e9nyfigyel\u0151 port (v\u00e9letlenszer\u0171, ha nincs be\u00e1ll\u00edtva)", + "poll_availability": "Eszk\u00f6z el\u00e9r\u00e9s\u00e9nek tesztel\u00e9se lek\u00e9rdez\u00e9ssel" + }, + "title": "DLNA konfigur\u00e1ci\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/it.json b/homeassistant/components/dlna_dmr/translations/it.json new file mode 100644 index 00000000000..5defd82a8be --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/it.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "could_not_connect": "Impossibile connettersi al dispositivo DLNA", + "discovery_error": "Impossibile individuare un dispositivo DLNA corrispondente", + "incomplete_config": "Nella configurazione manca una variabile richiesta", + "non_unique_id": "Pi\u00f9 dispositivi trovati con lo stesso ID univoco", + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer" + }, + "error": { + "could_not_connect": "Impossibile connettersi al dispositivo DLNA", + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vuoi iniziare la configurazione?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL di un file XML di descrizione del dispositivo", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "URL non valido" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL di richiamata dell'ascoltatore di eventi", + "listen_port": "Porta dell'ascoltatore di eventi (casuale se non impostata)", + "poll_availability": "Interrogazione per la disponibilit\u00e0 del dispositivo" + }, + "title": "Configurazione DLNA Digital Media Renderer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/nl.json b/homeassistant/components/dlna_dmr/translations/nl.json new file mode 100644 index 00000000000..7387494b9b7 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/nl.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", + "discovery_error": "Kan geen overeenkomend DLNA-apparaat vinden", + "incomplete_config": "Configuratie mist een vereiste variabele", + "non_unique_id": "Meerdere apparaten gevonden met hetzelfde unieke ID", + "not_dmr": "Apparaat is geen Digital Media Renderer" + }, + "error": { + "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", + "not_dmr": "Apparaat is geen Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Wilt u beginnen met instellen?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL naar een XML-bestand met apparaatbeschrijvingen", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Ongeldige URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Poort om naar gebeurtenissen te luisteren (willekeurige poort indien niet ingesteld)", + "poll_availability": "Pollen voor apparaat beschikbaarheid" + }, + "title": "DLNA Digital Media Renderer instellingen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/no.json b/homeassistant/components/dlna_dmr/translations/no.json new file mode 100644 index 00000000000..1ddbfc32afe --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/no.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "could_not_connect": "Kunne ikke koble til DLNA -enhet", + "discovery_error": "Kunne ikke finne en matchende DLNA -enhet", + "incomplete_config": "Konfigurasjonen mangler en n\u00f8dvendig variabel", + "non_unique_id": "Flere enheter ble funnet med samme unike ID", + "not_dmr": "Enheten er ikke en Digital Media Renderer" + }, + "error": { + "could_not_connect": "Kunne ikke koble til DLNA -enhet", + "not_dmr": "Enheten er ikke en Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL til en enhetsbeskrivelse XML -fil", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "ugyldig URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL for tilbakeringing av hendelseslytter", + "listen_port": "Hendelseslytterport (tilfeldig hvis den ikke er angitt)", + "poll_availability": "Avstemning for tilgjengelighet av enheter" + }, + "title": "DLNA Digital Media Renderer -konfigurasjon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/ru.json b/homeassistant/components/dlna_dmr/translations/ru.json new file mode 100644 index 00000000000..bf1be8f6c3d --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/ru.json @@ -0,0 +1,44 @@ +{ + "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.", + "could_not_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "discovery_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u043f\u043e\u0434\u0445\u043e\u0434\u044f\u0449\u0435\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e DLNA.", + "incomplete_config": "\u0412 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f.", + "non_unique_id": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u0441 \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u044b\u043c \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c.", + "not_dmr": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u043e\u043c (DMR)." + }, + "error": { + "could_not_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "not_dmr": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u043e\u043c (DMR)." + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + }, + "user": { + "data": { + "url": "URL-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "URL-\u0430\u0434\u0440\u0435\u0441 XML-\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "title": "\u041c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440 DLNA" + } + } + }, + "options": { + "error": { + "invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "init": { + "data": { + "callback_url_override": "Callback URL \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439", + "listen_port": "\u041f\u043e\u0440\u0442 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 (\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439, \u0435\u0441\u043b\u0438 \u043d\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d)", + "poll_availability": "\u041e\u043f\u0440\u043e\u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0441\u0442\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043c\u0435\u0434\u0438\u0430\u0440\u0435\u043d\u0434\u0435\u0440\u0435\u0440\u0430 DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/zh-Hans.json b/homeassistant/components/dlna_dmr/translations/zh-Hans.json new file mode 100644 index 00000000000..909a38b4b74 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "could_not_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 DLNA \u8bbe\u5907" + } + }, + "options": { + "error": { + "invalid_url": "\u65e0\u6548\u7f51\u5740" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/zh-Hant.json b/homeassistant/components/dlna_dmr/translations/zh-Hant.json new file mode 100644 index 00000000000..b7eab93d76d --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/zh-Hant.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "could_not_connect": "DLNA \u88dd\u7f6e\u9023\u7dda\u5931\u6557\u3002", + "discovery_error": "DLNA \u88dd\u7f6e\u63a2\u7d22\u5931\u6557", + "incomplete_config": "\u6240\u7f3a\u5c11\u7684\u8a2d\u5b9a\u70ba\u5fc5\u9808\u8b8a\u6578", + "non_unique_id": "\u627e\u5230\u591a\u7d44\u88dd\u7f6e\u4f7f\u7528\u4e86\u76f8\u540c\u552f\u4e00 ID", + "not_dmr": "\u88dd\u7f6e\u4e26\u975e Digital Media Renderer" + }, + "error": { + "could_not_connect": "DLNA \u88dd\u7f6e\u9023\u7dda\u5931\u6557\u3002", + "not_dmr": "\u88dd\u7f6e\u4e26\u975e Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + }, + "user": { + "data": { + "url": "\u7db2\u5740" + }, + "description": "\u88dd\u7f6e\u8aaa\u660e XML \u6a94\u6848\u4e4b URL", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "URL \u7121\u6548" + }, + "step": { + "init": { + "data": { + "callback_url_override": "\u4e8b\u4ef6\u76e3\u807d\u56de\u547c URL", + "listen_port": "\u4e8b\u4ef6\u76e3\u807d\u901a\u8a0a\u57e0\uff08\u672a\u8a2d\u7f6e\u5247\u70ba\u96a8\u6a5f\uff09", + "poll_availability": "\u67e5\u8a62\u88dd\u7f6e\u53ef\u7528\u6027" + }, + "title": "DLNA Digital Media Renderer \u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 07366ad1a9a..f1addbf477b 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, - HTTP_OK, HTTP_UNAUTHORIZED, ) from homeassistant.core import HomeAssistant, callback @@ -324,6 +323,7 @@ class DoorBirdRequestView(HomeAssistantView): async def get(self, request, event): """Respond to requests from the device.""" + # pylint: disable=no-self-use hass = request.app["hass"] token = request.query.get("token") @@ -344,7 +344,7 @@ class DoorBirdRequestView(HomeAssistantView): hass.bus.async_fire(RESET_DEVICE_FAVORITES, {"token": token}) message = f"HTTP Favorites cleared for {device.slug}" - return web.Response(status=HTTP_OK, text=message) + return web.Response(text=message) event_data[ATTR_ENTITY_ID] = hass.data[DOMAIN][ DOOR_STATION_EVENT_ENTITY_IDS @@ -352,4 +352,4 @@ class DoorBirdRequestView(HomeAssistantView): hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - return web.Response(status=HTTP_OK, text="OK") + return web.Response(text="OK") diff --git a/homeassistant/components/doorbird/translations/fr.json b/homeassistant/components/doorbird/translations/fr.json index fd8bf04d29e..92961908be4 100644 --- a/homeassistant/components/doorbird/translations/fr.json +++ b/homeassistant/components/doorbird/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce DoorBird est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "link_local_address": "Les adresses locales ne sont pas prises en charge", "not_doorbird_device": "Cet appareil n'est pas un DoorBird" }, @@ -14,10 +14,10 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom de l'appareil", "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous au DoorBird" } diff --git a/homeassistant/components/doorbird/translations/hu.json b/homeassistant/components/doorbird/translations/hu.json index cb4c46e699a..48a124b4f17 100644 --- a/homeassistant/components/doorbird/translations/hu.json +++ b/homeassistant/components/doorbird/translations/hu.json @@ -10,11 +10,11 @@ "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Eszk\u00f6z neve", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/doorbird/translations/id.json b/homeassistant/components/doorbird/translations/id.json index f708780ce31..60348ec26a1 100644 --- a/homeassistant/components/doorbird/translations/id.json +++ b/homeassistant/components/doorbird/translations/id.json @@ -10,7 +10,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "DoorBird {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/dsmr/translations/ca.json b/homeassistant/components/dsmr/translations/ca.json index 263cb388980..1d61426ebea 100644 --- a/homeassistant/components/dsmr/translations/ca.json +++ b/homeassistant/components/dsmr/translations/ca.json @@ -28,7 +28,7 @@ }, "setup_serial_manual_path": { "data": { - "port": "Ruta del port USB del dispositiu" + "port": "Ruta del dispositiu USB" }, "title": "Ruta" }, diff --git a/homeassistant/components/dsmr/translations/hu.json b/homeassistant/components/dsmr/translations/hu.json index 86a15e99aab..1bca962e2f5 100644 --- a/homeassistant/components/dsmr/translations/hu.json +++ b/homeassistant/components/dsmr/translations/hu.json @@ -18,7 +18,7 @@ "setup_network": { "data": { "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "port": "Port" }, "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 1e9834e7e5e..1c719bc890b 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,8 +1,9 @@ """Definitions for DSMR Reader sensors added to MQTT.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Final +from typing import Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/dunehd/translations/fr.json b/homeassistant/components/dunehd/translations/fr.json index 7547ceadb72..0e8cb6d6ff8 100644 --- a/homeassistant/components/dunehd/translations/fr.json +++ b/homeassistant/components/dunehd/translations/fr.json @@ -6,7 +6,7 @@ "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide." + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide" }, "step": { "user": { diff --git a/homeassistant/components/dunehd/translations/hu.json b/homeassistant/components/dunehd/translations/hu.json index 148a6fde0d0..15b2d297363 100644 --- a/homeassistant/components/dunehd/translations/hu.json +++ b/homeassistant/components/dunehd/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Dune HD integr\u00e1ci\u00f3t. Ha probl\u00e9m\u00e1i vannak a konfigur\u00e1ci\u00f3val, l\u00e1togasson el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/dunehd \n\n Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a lej\u00e1tsz\u00f3 be van kapcsolva.", "title": "Dune HD" diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 9c911e6983d..82666f20a40 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,8 +1,9 @@ """Code to handle a Dynalite bridge.""" from __future__ import annotations +from collections.abc import Callable from types import MappingProxyType -from typing import Any, Callable +from typing import Any from dynalite_devices_lib.dynalite_devices import ( CONF_AREA as dyn_CONF_AREA, diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 56def12afbe..72803f86f02 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -1,7 +1,8 @@ """Support for the Dynalite devices as entities.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from homeassistant.components.dynalite.bridge import DynaliteBridge from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 3c43dd36130..7ec453b7c3a 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -52,7 +52,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="balance", name="Balance", native_unit_of_measurement=PRICE, - icon="mdi:cash-usd", + icon="mdi:cash", ), SensorEntityDescription( key="limit", diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index eeac7ddb224..0e7a5e52fa7 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -676,7 +676,7 @@ class Thermostat(ClimateEntity): heatCoolMinDelta property. https://www.ecobee.com/home/developer/api/examples/ex5.shtml """ - if self.hvac_mode == HVAC_MODE_HEAT or self.hvac_mode == HVAC_MODE_COOL: + if self.hvac_mode in (HVAC_MODE_HEAT, HVAC_MODE_COOL): heat_temp = temp cool_temp = temp else: diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index dfa6cf4cb0a..47e7af66e57 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -3,7 +3,11 @@ from __future__ import annotations from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -19,12 +23,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Temperature", native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/ecobee/translations/fr.json b/homeassistant/components/ecobee/translations/fr.json index acbc909d881..8f8d0c42b59 100644 --- a/homeassistant/components/ecobee/translations/fr.json +++ b/homeassistant/components/ecobee/translations/fr.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez entrer la cl\u00e9 API obtenue aupr\u00e8s d'ecobee.com.", "title": "Cl\u00e9 API ecobee" diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 116b1243ee0..cb7945e0815 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -1,4 +1,6 @@ """Support for Rheem EcoNet water heaters.""" +from __future__ import annotations + from pyeconet.equipment import EquipmentType from homeassistant.components.binary_sensor import ( @@ -7,77 +9,72 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_SOUND, BinarySensorEntity, + BinarySensorEntityDescription, ) from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT -SENSOR_NAME_RUNNING = "running" -SENSOR_NAME_SHUTOFF_VALVE = "shutoff_valve" -SENSOR_NAME_RUNNING = "running" -SENSOR_NAME_SCREEN_LOCKED = "screen_locked" -SENSOR_NAME_BEEP_ENABLED = "beep_enabled" - -ATTR = "attr" -DEVICE_CLASS = "device_class" -SENSORS = { - SENSOR_NAME_SHUTOFF_VALVE: { - ATTR: "shutoff_valve_open", - DEVICE_CLASS: DEVICE_CLASS_OPENING, - }, - SENSOR_NAME_RUNNING: {ATTR: "running", DEVICE_CLASS: DEVICE_CLASS_POWER}, - SENSOR_NAME_SCREEN_LOCKED: { - ATTR: "screen_locked", - DEVICE_CLASS: DEVICE_CLASS_LOCK, - }, - SENSOR_NAME_BEEP_ENABLED: { - ATTR: "beep_enabled", - DEVICE_CLASS: DEVICE_CLASS_SOUND, - }, -} +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="shutoff_valve_open", + name="shutoff_valve", + device_class=DEVICE_CLASS_OPENING, + ), + BinarySensorEntityDescription( + key="running", + name="running", + device_class=DEVICE_CLASS_POWER, + ), + BinarySensorEntityDescription( + key="screen_locked", + name="screen_locked", + device_class=DEVICE_CLASS_LOCK, + ), + BinarySensorEntityDescription( + key="beep_enabled", + name="beep_enabled", + device_class=DEVICE_CLASS_SOUND, + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up EcoNet binary sensor based on a config entry.""" equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] - binary_sensors = [] all_equipment = equipment[EquipmentType.WATER_HEATER].copy() all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) - for _equip in all_equipment: - for sensor_name, sensor in SENSORS.items(): - if getattr(_equip, sensor[ATTR], None) is not None: - binary_sensors.append(EcoNetBinarySensor(_equip, sensor_name)) - async_add_entities(binary_sensors) + entities = [ + EcoNetBinarySensor(_equip, description) + for _equip in all_equipment + for description in BINARY_SENSOR_TYPES + if getattr(_equip, description.key, None) is not None + ] + + async_add_entities(entities) class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): """Define a Econet binary sensor.""" - def __init__(self, econet_device, device_name): + def __init__(self, econet_device, description: BinarySensorEntityDescription): """Initialize.""" super().__init__(econet_device) + self.entity_description = description self._econet = econet_device - self._device_name = device_name @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self._econet, SENSORS[self._device_name][ATTR]) - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return SENSORS[self._device_name][DEVICE_CLASS] + return getattr(self._econet, self.entity_description.key) @property def name(self): """Return the name of the entity.""" - return f"{self._econet.device_name}_{self._device_name}" + return f"{self._econet.device_name}_{self.entity_description.name}" @property def unique_id(self): """Return the unique ID of the entity.""" - return ( - f"{self._econet.device_id}_{self._econet.device_name}_{self._device_name}" - ) + return f"{self._econet.device_id}_{self._econet.device_name}_{self.entity_description.name}" diff --git a/homeassistant/components/econet/translations/fr.json b/homeassistant/components/econet/translations/fr.json index 64fd39c852a..e6081bef90a 100644 --- a/homeassistant/components/econet/translations/fr.json +++ b/homeassistant/components/econet/translations/fr.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" }, "error": { - "cannot_connect": "\u00c9chec de la connexion", - "invalid_auth": "Authentification invalide " + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index fe9ea7e6047..3b84d243d46 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -2,6 +2,7 @@ "domain": "efergy", "name": "Efergy", "documentation": "https://www.home-assistant.io/integrations/efergy", - "codeowners": [], + "requirements": ["pyefergy==0.0.3"], + "codeowners": ["@tkdrob"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 391aca7b4af..338609cf342 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,21 +1,29 @@ """Support for Efergy sensors.""" -import logging +from __future__ import annotations -import requests +from pyefergy import Efergy 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_CURRENCY, CONF_MONITORED_VARIABLES, CONF_TYPE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = "https://engage.efergy.com/mobile_proxy/" +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_APPTOKEN = "app_token" CONF_UTC_OFFSET = "utc_offset" @@ -31,12 +39,34 @@ CONF_CURRENT_VALUES = "current_values" DEFAULT_PERIOD = "year" DEFAULT_UTC_OFFSET = "0" -SENSOR_TYPES = { - CONF_INSTANT: ["Energy Usage", POWER_WATT], - CONF_AMOUNT: ["Energy Consumed", ENERGY_KILO_WATT_HOUR], - CONF_BUDGET: ["Energy Budget", None], - CONF_COST: ["Energy Cost", None], - CONF_CURRENT_VALUES: ["Per-Device Usage", POWER_WATT], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + CONF_INSTANT: SensorEntityDescription( + key=CONF_INSTANT, + name="Energy Usage", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_WATT, + ), + CONF_AMOUNT: SensorEntityDescription( + key=CONF_AMOUNT, + name="Energy Consumed", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + CONF_BUDGET: SensorEntityDescription( + key=CONF_BUDGET, + name="Energy Budget", + ), + CONF_COST: SensorEntityDescription( + key=CONF_COST, + name="Energy Cost", + device_class=DEVICE_CLASS_MONETARY, + ), + CONF_CURRENT_VALUES: SensorEntityDescription( + key=CONF_CURRENT_VALUES, + name="Per-Device Usage", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_WATT, + ), } TYPES_SCHEMA = vol.In(SENSOR_TYPES) @@ -58,35 +88,39 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: """Set up the Efergy sensor.""" - app_token = config.get(CONF_APPTOKEN) - utc_offset = str(config.get(CONF_UTC_OFFSET)) + api = Efergy( + config[CONF_APPTOKEN], + async_get_clientsession(hass), + utc_offset=config[CONF_UTC_OFFSET], + ) dev = [] + sensors = await api.get_sids() for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_TYPE] == CONF_CURRENT_VALUES: - url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}" - response = requests.get(url_string, timeout=10) - for sensor in response.json(): - sid = sensor["sid"] + for sensor in sensors: dev.append( EfergySensor( - variable[CONF_TYPE], - app_token, - utc_offset, + api, variable[CONF_PERIOD], variable[CONF_CURRENCY], - sid, + SENSOR_TYPES[variable[CONF_TYPE]], + sid=sensor["sid"], ) ) dev.append( EfergySensor( - variable[CONF_TYPE], - app_token, - utc_offset, + api, variable[CONF_PERIOD], variable[CONF_CURRENCY], + SENSOR_TYPES[variable[CONF_TYPE]], ) ) @@ -96,68 +130,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class EfergySensor(SensorEntity): """Implementation of an Efergy sensor.""" - def __init__(self, sensor_type, app_token, utc_offset, period, currency, sid=None): + def __init__( + self, + api: Efergy, + period: str, + currency: str, + description: SensorEntityDescription, + sid: str = None, + ) -> None: """Initialize the sensor.""" + self.entity_description = description self.sid = sid - if sid: - self._name = f"efergy_{sid}" - else: - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type - self.app_token = app_token - self.utc_offset = utc_offset - self._state = None + self.api = api self.period = period - self.currency = currency - if self.type == "cost": - self._unit_of_measurement = f"{self.currency}/{self.period}" - else: - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + if sid: + self._attr_name = f"efergy_{sid}" + if description.key == CONF_COST: + self._attr_native_unit_of_measurement = f"{currency}/{period}" - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - def update(self): + async def async_update(self) -> None: """Get the Efergy monitor data from the web service.""" - try: - if self.type == "instant_readings": - url_string = f"{_RESOURCE}getInstant?token={self.app_token}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["reading"] - elif self.type == "amount": - url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["sum"] - elif self.type == "budget": - url_string = f"{_RESOURCE}getBudget?token={self.app_token}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["status"] - elif self.type == "cost": - url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}" - response = requests.get(url_string, timeout=10) - self._state = response.json()["sum"] - elif self.type == "current_values": - url_string = ( - f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}" - ) - response = requests.get(url_string, timeout=10) - for sensor in response.json(): - if self.sid == sensor["sid"]: - measurement = next(iter(sensor["data"][0].values())) - self._state = measurement - else: - self._state = None - except (requests.RequestException, ValueError, KeyError): - _LOGGER.warning("Could not update status for %s", self.name) + self._attr_native_value = await self.api.async_get_reading( + self.entity_description.key, period=self.period, sid=self.sid + ) diff --git a/homeassistant/components/elgato/translations/fr.json b/homeassistant/components/elgato/translations/fr.json index ccc325c84e2..6cd1cd247a7 100644 --- a/homeassistant/components/elgato/translations/fr.json +++ b/homeassistant/components/elgato/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cet appareil Elgato Key Light est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "error": { @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "description": "Configurez votre Elgato Key Light pour l'int\u00e9grer \u00e0 Home Assistant." diff --git a/homeassistant/components/elgato/translations/hu.json b/homeassistant/components/elgato/translations/hu.json index 0cd9f2589b8..26740a33f21 100644 --- a/homeassistant/components/elgato/translations/hu.json +++ b/homeassistant/components/elgato/translations/hu.json @@ -7,11 +7,11 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "description": "\u00c1ll\u00edtsa be az Elgato Light-ot, hogy integr\u00e1lhat\u00f3 legyen az HomeAssistantba." diff --git a/homeassistant/components/elgato/translations/id.json b/homeassistant/components/elgato/translations/id.json index b06691b9453..f9fa5690c1d 100644 --- a/homeassistant/components/elgato/translations/id.json +++ b/homeassistant/components/elgato/translations/id.json @@ -7,18 +7,18 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "{serial_number}", "step": { "user": { "data": { "host": "Host", "port": "Port" }, - "description": "Siapkan Elgato Key Light Anda untuk diintegrasikan dengan Home Assistant." + "description": "Siapkan Elgato Light Anda untuk diintegrasikan dengan Home Assistant." }, "zeroconf_confirm": { - "description": "Ingin menambahkan Elgato Key Light dengan nomor seri `{serial_number}` ke Home Assistant?", - "title": "Perangkat Elgato Key Light yang ditemukan" + "description": "Ingin menambahkan Elgato Light dengan nomor seri `{serial_number}` ke Home Assistant?", + "title": "Perangkat Elgato Light yang ditemukan" } } } diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 6a96a73de22..a392fbd302a 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -1,7 +1,11 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" +from __future__ import annotations + import asyncio import logging import re +from types import MappingProxyType +from typing import Any import async_timeout import elkm1_lib as elkm1 @@ -197,7 +201,7 @@ def _async_find_matching_config_entry(hass, prefix): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" - conf = entry.data + conf: MappingProxyType[str, Any] = entry.data _LOGGER.debug("Setting up elkm1 %s", conf["host"]) @@ -205,7 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if conf[CONF_TEMPERATURE_UNIT] in (BARE_TEMP_CELSIUS, TEMP_CELSIUS): temperature_unit = TEMP_CELSIUS - config = {"temperature_unit": temperature_unit} + config: dict[str, Any] = {"temperature_unit": temperature_unit} if not conf[CONF_AUTO_CONFIGURE]: # With elkm1-lib==0.7.16 and later auto configure is available diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 6d10df45adf..bc5f3ae4b7a 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -65,8 +65,9 @@ class ElkThermostat(ElkEntity, ClimateEntity): @property def target_temperature(self): """Return the temperature we are trying to reach.""" - if (self._element.mode == ThermostatMode.HEAT.value) or ( - self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + if self._element.mode in ( + ThermostatMode.HEAT.value, + ThermostatMode.EMERGENCY_HEAT.value, ): return self._element.heat_setpoint if self._element.mode == ThermostatMode.COOL.value: diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 3f72ecfd7a7..3b341d90669 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -2,7 +2,7 @@ "domain": "elkm1", "name": "Elk-M1 Control", "documentation": "https://www.home-assistant.io/integrations/elkm1", - "requirements": ["elkm1-lib==0.8.10"], + "requirements": ["elkm1-lib==1.0.0"], "codeowners": ["@gwww", "@bdraco"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/elkm1/translations/fr.json b/homeassistant/components/elkm1/translations/fr.json index 618299def29..665ac4b4d92 100644 --- a/homeassistant/components/elkm1/translations/fr.json +++ b/homeassistant/components/elkm1/translations/fr.json @@ -5,8 +5,8 @@ "already_configured": "Un ElkM1 avec ce pr\u00e9fixe est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/emonitor/translations/fr.json b/homeassistant/components/emonitor/translations/fr.json index fcfee3bc710..9557160e335 100644 --- a/homeassistant/components/emonitor/translations/fr.json +++ b/homeassistant/components/emonitor/translations/fr.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Hote" + "host": "H\u00f4te" } } } diff --git a/homeassistant/components/emonitor/translations/hu.json b/homeassistant/components/emonitor/translations/hu.json index 575e2a91d44..1a4fbb292e0 100644 --- a/homeassistant/components/emonitor/translations/hu.json +++ b/homeassistant/components/emonitor/translations/hu.json @@ -10,12 +10,12 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "A SiteSage Emonitor be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/emonitor/translations/id.json b/homeassistant/components/emonitor/translations/id.json index 1365fed7d52..c967ad91d05 100644 --- a/homeassistant/components/emonitor/translations/id.json +++ b/homeassistant/components/emonitor/translations/id.json @@ -7,7 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "SiteSage {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 1ee5e19caa7..3cfa710703c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -4,7 +4,8 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant import util +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( CONF_ENTITIES, CONF_TYPE, @@ -105,7 +106,9 @@ ATTR_EMULATED_HUE_NAME = "emulated_hue_name" async def async_setup(hass, yaml_config): """Activate the emulated_hue component.""" - config = Config(hass, yaml_config.get(DOMAIN, {})) + local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) + config = Config(hass, yaml_config.get(DOMAIN, {}), local_ip) + await config.async_setup() app = web.Application() app["hass"] = hass @@ -156,7 +159,6 @@ async def async_setup(hass, yaml_config): nonlocal protocol nonlocal site nonlocal runner - await config.async_setup() _, protocol = await listen @@ -186,7 +188,7 @@ async def async_setup(hass, yaml_config): class Config: """Hold configuration variables for the emulated hue bridge.""" - def __init__(self, hass, conf): + def __init__(self, hass, conf, local_ip): """Initialize the instance.""" self.hass = hass self.type = conf.get(CONF_TYPE) @@ -204,11 +206,7 @@ class Config: # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) if self.host_ip_addr is None: - self.host_ip_addr = util.get_local_ip() - _LOGGER.info( - "Listen IP address not specified, auto-detected address is %s", - self.host_ip_addr, - ) + self.host_ip_addr = local_ip # Get the port that the Hue bridge will listen on self.listen_port = conf.get(CONF_LISTEN_PORT) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index bbd899b559b..a7106f5105f 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,6 +1,7 @@ """Support for a Hue API to control Home Assistant.""" import asyncio import hashlib +from http import HTTPStatus from ipaddress import ip_address import logging import time @@ -55,9 +56,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_TURN_OFF, @@ -136,15 +134,15 @@ class HueUsernameView(HomeAssistantView): async def post(self, request): """Handle a POST request.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) if "devicetype" not in data: - return self.json_message("devicetype not specified", HTTP_BAD_REQUEST) + return self.json_message("devicetype not specified", HTTPStatus.BAD_REQUEST) return self.json([{"success": {"username": HUE_API_USERNAME}}]) @@ -164,7 +162,7 @@ class HueAllGroupsStateView(HomeAssistantView): def get(self, request, username): """Process a request to make the Brilliant Lightpad work.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json({}) @@ -184,7 +182,7 @@ class HueGroupView(HomeAssistantView): def put(self, request, username): """Process a request to make the Logitech Pop working.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json( [ @@ -214,7 +212,7 @@ class HueAllLightsStateView(HomeAssistantView): def get(self, request, username): """Process a request to get the list of available lights.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json(create_list_of_entities(self.config, request)) @@ -234,7 +232,7 @@ class HueFullStateView(HomeAssistantView): def get(self, request, username): """Process a request to get the list of available lights.""" if not is_local(ip_address(request.remote)): - return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: return self.json(UNAUTHORIZED_USER) @@ -262,7 +260,7 @@ class HueConfigView(HomeAssistantView): def get(self, request, username=""): """Process a request to get the configuration.""" if not is_local(ip_address(request.remote)): - return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) json_response = create_config_model(self.config, request) @@ -284,7 +282,7 @@ class HueOneLightStateView(HomeAssistantView): def get(self, request, username, entity_id): """Process a request to get the state of an individual light.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) hass = request.app["hass"] hass_entity_id = self.config.number_to_entity_id(entity_id) @@ -294,17 +292,17 @@ class HueOneLightStateView(HomeAssistantView): "Unknown entity number: %s not found in emulated_hue_ids.json", entity_id, ) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) entity = hass.states.get(hass_entity_id) if entity is None: _LOGGER.error("Entity not found: %s", hass_entity_id) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) if not self.config.is_entity_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) - return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) json_response = entity_to_json(self.config, entity) @@ -325,7 +323,7 @@ class HueOneLightChangeView(HomeAssistantView): async def put(self, request, username, entity_number): # noqa: C901 """Process a request to set the state of an individual light.""" if not is_local(ip_address(request.remote)): - return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config hass = request.app["hass"] @@ -333,23 +331,23 @@ class HueOneLightChangeView(HomeAssistantView): if entity_id is None: _LOGGER.error("Unknown entity number: %s", entity_number) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) entity = hass.states.get(entity_id) if entity is None: _LOGGER.error("Entity not found: %s", entity_id) - return self.json_message("Entity not found", HTTP_NOT_FOUND) + return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) if not config.is_entity_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) - return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) try: request_json = await request.json() except ValueError: _LOGGER.error("Received invalid json") - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) # Get the entity's supported features entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -370,7 +368,7 @@ class HueOneLightChangeView(HomeAssistantView): if HUE_API_STATE_ON in request_json: if not isinstance(request_json[HUE_API_STATE_ON], bool): _LOGGER.error("Unable to parse data: %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: parsed[STATE_ON] = entity.state != STATE_OFF @@ -387,7 +385,7 @@ class HueOneLightChangeView(HomeAssistantView): parsed[attr] = int(request_json[key]) except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) if HUE_API_STATE_XY in request_json: try: parsed[STATE_XY] = ( @@ -396,7 +394,7 @@ class HueOneLightChangeView(HomeAssistantView): ) except ValueError: _LOGGER.error("Unable to parse data (2): %s", request_json) - return self.json_message("Bad request", HTTP_BAD_REQUEST) + return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) if HUE_API_STATE_BRI in request_json: if entity.domain == light.DOMAIN: diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 406451639f2..e5a9072e51d 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -3,6 +3,7 @@ "name": "Emulated Hue", "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "requirements": ["aiohttp_cors==0.7.0"], + "dependencies": ["network"], "after_dependencies": ["http"], "codeowners": [], "quality_scale": "internal", diff --git a/homeassistant/components/emulated_roku/translations/hu.json b/homeassistant/components/emulated_roku/translations/hu.json index e733e9801df..53b66f6db19 100644 --- a/homeassistant/components/emulated_roku/translations/hu.json +++ b/homeassistant/components/emulated_roku/translations/hu.json @@ -8,8 +8,8 @@ "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", + "host_ip": "IP c\u00edm", + "listen_port": "Port", "name": "N\u00e9v", "upnp_bind_multicast": "K\u00f6t\u00f6tt multicast (igaz/hamis)" }, diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 1cea20564b4..f8c14ed8b73 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio from collections import Counter -from collections.abc import Awaitable -from typing import Callable, Literal, Optional, TypedDict, Union, cast +from collections.abc import Awaitable, Callable +from typing import Literal, Optional, TypedDict, Union, cast import voluptuous as vol diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 5db085343bc..0c4c5eeb3b9 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,6 +1,7 @@ """Helper sensor for calculating utility costs.""" from __future__ import annotations +import asyncio import copy from dataclasses import dataclass import logging @@ -11,6 +12,7 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -32,6 +34,7 @@ from .data import EnergyManager, async_get_manager SUPPORTED_STATE_CLASSES = [ STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ] _LOGGER = logging.getLogger(__name__) @@ -115,12 +118,13 @@ class SensorManager: async def _process_manager_data(self) -> None: """Process manager data.""" - to_add: list[SensorEntity] = [] + to_add: list[EnergyCostSensor] = [] to_remove = dict(self.current_entities) async def finish() -> None: if to_add: self.async_add_entities(to_add) + await asyncio.gather(*(ent.add_finished.wait() for ent in to_add)) for key, entity in to_remove.items(): self.current_entities.pop(key) @@ -161,7 +165,7 @@ class SensorManager: self, adapter: SourceAdapter, config: dict, - to_add: list[SensorEntity], + to_add: list[EnergyCostSensor], to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], ) -> None: """Process sensor data.""" @@ -214,15 +218,16 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL self._config = config self._last_energy_sensor_state: State | None = None - self._cur_value = 0.0 + # add_finished is set when either of async_added_to_hass or add_to_platform_abort + # is called + self.add_finished = asyncio.Event() def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_native_value = 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() @@ -332,8 +337,8 @@ class EnergyCostSensor(SensorEntity): self._reset(energy_state_copy) # 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_native_value = round(self._cur_value, 2) + cur_value = cast(float, self._attr_native_value) + self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price self._last_energy_sensor_state = energy_state @@ -371,6 +376,12 @@ class EnergyCostSensor(SensorEntity): async_state_changed_listener, ) ) + self.add_finished.set() + + @callback + def add_to_platform_abort(self) -> None: + """Abort adding an entity to a platform.""" + self.add_finished.set() async def async_will_remove_from_hass(self) -> None: """Handle removing from hass.""" diff --git a/homeassistant/components/energy/translations/el.json b/homeassistant/components/energy/translations/el.json new file mode 100644 index 00000000000..cdc7b83c2ee --- /dev/null +++ b/homeassistant/components/energy/translations/el.json @@ -0,0 +1,3 @@ +{ + "title": "\u0395\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 7097788aa30..24d060b4352 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -1,12 +1,13 @@ """Validate the energy preferences provide valid data.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence import dataclasses from typing import Any from homeassistant.components import recorder, sensor from homeassistant.const import ( + ATTR_DEVICE_CLASS, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, STATE_UNAVAILABLE, @@ -19,15 +20,25 @@ from homeassistant.core import HomeAssistant, callback, valid_entity_id from . import data from .const import DOMAIN -ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +ENERGY_USAGE_DEVICE_CLASSES = (sensor.DEVICE_CLASS_ENERGY,) +ENERGY_USAGE_UNITS = { + sensor.DEVICE_CLASS_ENERGY: (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +} +ENERGY_PRICE_UNITS = tuple( + f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units +) ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" -GAS_USAGE_UNITS = ( - ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, - VOLUME_CUBIC_METERS, - VOLUME_CUBIC_FEET, +ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price" +GAS_USAGE_DEVICE_CLASSES = (sensor.DEVICE_CLASS_ENERGY, sensor.DEVICE_CLASS_GAS) +GAS_USAGE_UNITS = { + sensor.DEVICE_CLASS_ENERGY: (ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), + sensor.DEVICE_CLASS_GAS: (VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET), +} +GAS_PRICE_UNITS = tuple( + f"/{unit}" for units in GAS_USAGE_UNITS.values() for unit in units ) GAS_UNIT_ERROR = "entity_unexpected_unit_gas" +GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price" @dataclasses.dataclass @@ -59,7 +70,8 @@ class EnergyPreferencesValidation: def _async_validate_usage_stat( hass: HomeAssistant, stat_value: str, - allowed_units: Sequence[str], + allowed_device_classes: Sequence[str], + allowed_units: Mapping[str, Sequence[str]], unit_error: str, result: list[ValidationIssue], ) -> None: @@ -106,30 +118,53 @@ def _async_validate_usage_stat( ValidationIssue("entity_negative_state", stat_value, current_value) ) - unit = state.attributes.get("unit_of_measurement") - - if unit not in allowed_units: - result.append(ValidationIssue(unit_error, stat_value, unit)) - - state_class = state.attributes.get("state_class") - - supported_state_classes = [ - sensor.STATE_CLASS_MEASUREMENT, - sensor.STATE_CLASS_TOTAL_INCREASING, - ] - if state_class not in supported_state_classes: + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + if device_class not in allowed_device_classes: result.append( ValidationIssue( - "entity_unexpected_state_class_total_increasing", + "entity_unexpected_device_class", + stat_value, + device_class, + ) + ) + else: + unit = state.attributes.get("unit_of_measurement") + + if device_class and unit not in allowed_units.get(device_class, []): + result.append(ValidationIssue(unit_error, stat_value, unit)) + + state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + + allowed_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in allowed_state_classes: + result.append( + ValidationIssue( + "entity_unexpected_state_class", stat_value, state_class, ) ) + if ( + state_class == sensor.STATE_CLASS_MEASUREMENT + and sensor.ATTR_LAST_RESET not in state.attributes + ): + result.append( + ValidationIssue("entity_state_class_measurement_no_last_reset", stat_value) + ) + @callback def _async_validate_price_entity( - hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] + hass: HomeAssistant, + entity_id: str, + result: list[ValidationIssue], + allowed_units: tuple[str, ...], + unit_error: str, ) -> None: """Validate that the price entity is correct.""" state = hass.states.get(entity_id) @@ -153,10 +188,8 @@ def _async_validate_price_entity( unit = state.attributes.get("unit_of_measurement") - if unit is None or not unit.endswith( - (f"/{ENERGY_KILO_WATT_HOUR}", f"/{ENERGY_WATT_HOUR}") - ): - result.append(ValidationIssue("entity_unexpected_unit_price", entity_id, unit)) + if unit is None or not unit.endswith(allowed_units): + result.append(ValidationIssue(unit_error, entity_id, unit)) @callback @@ -177,27 +210,13 @@ def _async_validate_cost_stat( ) ) - -@callback -def _async_validate_cost_entity( - hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] -) -> None: - """Validate that the cost entity is correct.""" - if not recorder.is_entity_recorded(hass, entity_id): - result.append( - ValidationIssue( - "recorder_untracked", - entity_id, - ) - ) - - state = hass.states.get(entity_id) + state = hass.states.get(stat_id) if state is None: result.append( ValidationIssue( "entity_not_defined", - entity_id, + stat_id, ) ) return @@ -206,12 +225,33 @@ def _async_validate_cost_entity( supported_state_classes = [ sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL, sensor.STATE_CLASS_TOTAL_INCREASING, ] if state_class not in supported_state_classes: + result.append( + ValidationIssue("entity_unexpected_state_class", stat_id, state_class) + ) + + if ( + state_class == sensor.STATE_CLASS_MEASUREMENT + and sensor.ATTR_LAST_RESET not in state.attributes + ): + result.append( + ValidationIssue("entity_state_class_measurement_no_last_reset", stat_id) + ) + + +@callback +def _async_validate_auto_generated_cost_entity( + hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the auto generated cost entity is correct.""" + if not recorder.is_entity_recorded(hass, entity_id): result.append( ValidationIssue( - "entity_unexpected_state_class_total_increasing", entity_id, state_class + "recorder_untracked", + entity_id, ) ) @@ -234,6 +274,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, flow["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -241,12 +282,20 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if flow.get("stat_cost") is not None: _async_validate_cost_stat(hass, flow["stat_cost"], source_result) - elif flow.get("entity_energy_price") is not None: _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, ) - _async_validate_cost_entity( + + if ( + flow.get("entity_energy_price") is not None + or flow.get("number_energy_price") is not None + ): + _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]], source_result, @@ -256,6 +305,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, flow["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -265,12 +315,20 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_cost_stat( hass, flow["stat_compensation"], source_result ) - elif flow.get("entity_energy_price") is not None: _async_validate_price_entity( - hass, flow["entity_energy_price"], source_result + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, ) - _async_validate_cost_entity( + + if ( + flow.get("entity_energy_price") is not None + or flow.get("number_energy_price") is not None + ): + _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]], source_result, @@ -280,6 +338,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_from"], + GAS_USAGE_DEVICE_CLASSES, GAS_USAGE_UNITS, GAS_UNIT_ERROR, source_result, @@ -287,12 +346,20 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source.get("stat_cost") is not None: _async_validate_cost_stat(hass, source["stat_cost"], source_result) - elif source.get("entity_energy_price") is not None: _async_validate_price_entity( - hass, source["entity_energy_price"], source_result + hass, + source["entity_energy_price"], + source_result, + GAS_PRICE_UNITS, + GAS_PRICE_UNIT_ERROR, ) - _async_validate_cost_entity( + + if ( + source.get("entity_energy_price") is not None + or source.get("number_energy_price") is not None + ): + _async_validate_auto_generated_cost_entity( hass, hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]], source_result, @@ -302,6 +369,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -311,6 +379,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -318,6 +387,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, source["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, source_result, @@ -329,6 +399,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: _async_validate_usage_stat( hass, device["stat_consumption"], + ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, device_result, diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index ff42ef23746..ea67b5b633b 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -27,7 +27,7 @@ SENSORS = ( key="daily_production", name="Today's Energy Production", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index a682f53bc44..9e948eaf842 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -3,7 +3,7 @@ "name": "Enphase Envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "requirements": [ - "envoy_reader==0.19.0" + "envoy_reader==0.20.0" ], "codeowners": [ "@gtdiehl" diff --git a/homeassistant/components/enphase_envoy/translations/fr.json b/homeassistant/components/enphase_envoy/translations/fr.json index 9587739e88a..a369562d70c 100644 --- a/homeassistant/components/enphase_envoy/translations/fr.json +++ b/homeassistant/components/enphase_envoy/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe", "username": "Nom d'utilisateur" } diff --git a/homeassistant/components/enphase_envoy/translations/hu.json b/homeassistant/components/enphase_envoy/translations/hu.json index ab92a4ad2bb..38177f8930c 100644 --- a/homeassistant/components/enphase_envoy/translations/hu.json +++ b/homeassistant/components/enphase_envoy/translations/hu.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/enphase_envoy/translations/id.json b/homeassistant/components/enphase_envoy/translations/id.json index ba3f8dd8cc6..31c8251820d 100644 --- a/homeassistant/components/enphase_envoy/translations/id.json +++ b/homeassistant/components/enphase_envoy/translations/id.json @@ -9,7 +9,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Envoy {serial} ({host})", + "flow_title": "{serial} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index cad8a49884f..3256b26171b 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -1,4 +1,6 @@ """Real-time information about public transport departures in Norway.""" +from __future__ import annotations + from datetime import datetime, timedelta from enturclient import EnturPublicTransportData @@ -158,9 +160,9 @@ class EnturPublicTransportSensor(SensorEntity): self._stop = stop self._show_on_map = show_on_map self._name = name - self._state = None + self._state: int | None = None self._icon = ICONS[DEFAULT_ICON_KEY] - self._attributes = {} + self._attributes: dict[str, str] = {} @property def name(self) -> str: @@ -168,7 +170,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._name @property - def native_value(self) -> str: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state @@ -195,7 +197,7 @@ class EnturPublicTransportSensor(SensorEntity): self._attributes = {} - data = self.api.get_stop_info(self._stop) + data: EnturPublicTransportData = self.api.get_stop_info(self._stop) if data is None: self._state = None return diff --git a/homeassistant/components/epson/translations/fr.json b/homeassistant/components/epson/translations/fr.json index 51a18284e73..c07a305a677 100644 --- a/homeassistant/components/epson/translations/fr.json +++ b/homeassistant/components/epson/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "Echec de la connection", + "cannot_connect": "\u00c9chec de connexion", "powered_off": "Le projecteur est-il allum\u00e9? Vous devez allumer le projecteur pour la configuration initiale." }, "step": { diff --git a/homeassistant/components/epson/translations/hu.json b/homeassistant/components/epson/translations/hu.json index 8e0d7ec9a18..e3aa507b7c1 100644 --- a/homeassistant/components/epson/translations/hu.json +++ b/homeassistant/components/epson/translations/hu.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" } } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2e33742b8e5..de301e0c1bb 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field import functools import logging import math -from typing import Any, Callable, Generic, TypeVar, cast, overload +from typing import Any, Callable, Generic, NamedTuple, TypeVar, cast, overload from aioesphomeapi import ( APIClient, @@ -18,11 +18,13 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, UserService, UserServiceArgType, ) import voluptuous as vol -from zeroconf import DNSPointer, DNSRecord, RecordUpdateListener, Zeroconf +from zeroconf import DNSPointer, RecordUpdate, RecordUpdateListener, Zeroconf from homeassistant import const from homeassistant.components import zeroconf @@ -52,6 +54,7 @@ from homeassistant.helpers.template import Template from .entry_data import RuntimeEntryData DOMAIN = "esphome" +CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") @@ -110,6 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] + noise_psk = entry.data.get(CONF_NOISE_PSK) device_id = None zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -121,6 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password, client_info=f"Home Assistant {const.__version__}", zeroconf_instance=zeroconf_instance, + noise_psk=noise_psk, ) domain_data = DomainData.get(hass) @@ -221,7 +226,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only communicate changes to the state or attribute tracked if ( - "old_state" in event.data + event.data.get("old_state") is not None and "new_state" in event.data and ( ( @@ -399,6 +404,11 @@ class ReconnectLogic(RecordUpdateListener): try: await self._cli.connect(on_stop=self._on_disconnect, login=True) except APIConnectionError as error: + if isinstance( + error, (RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError) + ): + self._entry.async_start_reauth(self._hass) + level = logging.WARNING if tries == 0 else logging.DEBUG _LOGGER.log( level, @@ -493,16 +503,14 @@ class ReconnectLogic(RecordUpdateListener): """ async with self._zc_lock: if not self._zc_listening: - await self._hass.async_add_executor_job( - self._zc.add_listener, self, None - ) + self._zc.async_add_listener(self, None) self._zc_listening = True async def _stop_zc_listen(self) -> None: """Stop listening for zeroconf updates.""" async with self._zc_lock: if self._zc_listening: - await self._hass.async_add_executor_job(self._zc.remove_listener, self) + self._zc.async_remove_listener(self) self._zc_listening = False @callback @@ -510,34 +518,40 @@ class ReconnectLogic(RecordUpdateListener): """Stop as an async callback function.""" self._hass.async_create_task(self.stop()) - @callback - def _set_reconnect(self) -> None: - self._reconnect_event.set() + def async_update_records( + self, zc: Zeroconf, now: float, records: list[RecordUpdate] + ) -> None: + """Listen to zeroconf updated mDNS records. - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Listen to zeroconf updated mDNS records.""" - if not isinstance(record, DNSPointer): - # We only consider PTR records and match using the alias name - return - if self._entry_data is None or self._entry_data.device_info is None: - # Either the entry was already teared down or we haven't received device info yet + This is a mDNS record from the device and could mean it just woke up. + """ + # Check if already connected, no lock needed for this access and + # bail if either the entry was already teared down or we haven't received device info yet + if ( + self._connected + or self._reconnect_event.is_set() + or self._entry_data is None + or self._entry_data.device_info is None + ): return filter_alias = f"{self._entry_data.device_info.name}._esphomelib._tcp.local." - if record.alias != filter_alias: - return - # This is a mDNS record from the device and could mean it just woke up - # Check if already connected, no lock needed for this access - if self._connected: - return + for record_update in records: + # We only consider PTR records and match using the alias name + if ( + not isinstance(record_update.new, DNSPointer) + or record_update.new.alias != filter_alias + ): + continue - # Tell reconnection logic to retry connection attempt now (even before reconnect timer finishes) - _LOGGER.debug( - "%s: Triggering reconnect because of received mDNS record %s", - self._host, - record, - ) - self._hass.add_job(self._set_reconnect) + # Tell reconnection logic to retry connection attempt now (even before reconnect timer finishes) + _LOGGER.debug( + "%s: Triggering reconnect because of received mDNS record %s", + self._host, + record_update.new, + ) + self._reconnect_event.set() + return async def _async_setup_device_registry( @@ -559,51 +573,60 @@ async def _async_setup_device_registry( return device_entry.id +class ServiceMetadata(NamedTuple): + """Metadata for services.""" + + validator: Any + example: str + selector: dict[str, Any] + description: str | None = None + + ARG_TYPE_METADATA = { - UserServiceArgType.BOOL: { - "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": {}}, - }, + UserServiceArgType.BOOL: ServiceMetadata( + validator=cv.boolean, + example="False", + selector={"boolean": None}, + ), + UserServiceArgType.INT: ServiceMetadata( + validator=vol.Coerce(int), + example="42", + selector={"number": {CONF_MODE: "box"}}, + ), + UserServiceArgType.FLOAT: ServiceMetadata( + validator=vol.Coerce(float), + example="12.3", + selector={"number": {CONF_MODE: "box", "step": 1e-3}}, + ), + UserServiceArgType.STRING: ServiceMetadata( + validator=cv.string, + example="Example text", + selector={"text": None}, + ), + UserServiceArgType.BOOL_ARRAY: ServiceMetadata( + validator=[cv.boolean], + description="A list of boolean values.", + example="[True, False]", + selector={"object": {}}, + ), + UserServiceArgType.INT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(int)], + description="A list of integer values.", + example="[42, 34]", + selector={"object": {}}, + ), + UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(float)], + description="A list of floating point numbers.", + example="[ 12.3, 34.5 ]", + selector={"object": {}}, + ), + UserServiceArgType.STRING_ARRAY: ServiceMetadata( + validator=[cv.string], + description="A list of strings.", + example="['Example text', 'Another example']", + selector={"object": {}}, + ), } @@ -626,13 +649,13 @@ async def _register_service( ) return metadata = ARG_TYPE_METADATA[arg.type] - schema[vol.Required(arg.name)] = metadata["validator"] + schema[vol.Required(arg.name)] = metadata.validator fields[arg.name] = { "name": arg.name, "required": True, - "description": metadata.get("description"), - "example": metadata["example"], - "selector": metadata["selector"], + "description": metadata.description, + "example": metadata.example, + "selector": metadata.selector, } async def execute_service(call: ServiceCall) -> None: @@ -805,6 +828,7 @@ def esphome_state_property(func: _PropT) -> _PropT: @property # type: ignore[misc] @functools.wraps(func) def _wrapper(self): # type: ignore[no-untyped-def] + # pylint: disable=protected-access if not self._has_state: return None val = func(self) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 940fee11076..7a7e45c440b 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -4,7 +4,15 @@ from __future__ import annotations from collections import OrderedDict from typing import Any -from aioesphomeapi import APIClient, APIConnectionError, DeviceInfo +from aioesphomeapi import ( + APIClient, + APIConnectionError, + DeviceInfo, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, + ResolveAPIError, +) import voluptuous as vol from homeassistant.components import zeroconf @@ -14,7 +22,9 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType -from . import DOMAIN, DomainData +from . import CONF_NOISE_PSK, DOMAIN, DomainData + +ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -27,12 +37,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._port: int | None = None self._password: str | None = None + self._noise_psk: str | None = None + self._device_info: DeviceInfo | None = None 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) + return await self._async_try_fetch_device_info(user_input) fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str @@ -52,6 +64,36 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self._async_step_user_base(user_input=user_input) + async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle a flow initialized by a reauth event.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + self._password = entry.data[CONF_PASSWORD] + self._noise_psk = entry.data.get(CONF_NOISE_PSK) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + self._noise_psk = user_input[CONF_NOISE_PSK] + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), + errors=errors, + description_placeholders={"name": self._name}, + ) + @property def _name(self) -> str | None: return self.context.get(CONF_NAME) @@ -67,18 +109,21 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host = user_input[CONF_HOST] self._port = user_input[CONF_PORT] - async def _async_authenticate_or_add( + async def _async_try_fetch_device_info( self, user_input: dict[str, Any] | None ) -> FlowResult: self._set_user_input(user_input) - error, device_info = await self.fetch_device_info() + error = await self.fetch_device_info() + if error == ERROR_REQUIRES_ENCRYPTION_KEY: + return await self.async_step_encryption_key() if error is not None: return await self._async_step_user_base(error=error) - assert device_info is not None - self._name = device_info.name + return await self._async_authenticate_or_add() + async def _async_authenticate_or_add(self) -> FlowResult: # Only show authentication step if device uses password - if device_info.uses_password: + assert self._device_info is not None + if self._device_info.uses_password: return await self.async_step_authenticate() return self._async_get_entry() @@ -88,7 +133,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: - return await self._async_authenticate_or_add(None) + return await self._async_try_fetch_device_info(None) return self.async_show_form( step_id="discovery_confirm", description_placeholders={"name": self._name} ) @@ -144,15 +189,47 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @callback def _async_get_entry(self) -> FlowResult: + config_data = { + CONF_HOST: self._host, + CONF_PORT: self._port, + # The API uses protobuf, so empty string denotes absence + CONF_PASSWORD: self._password or "", + CONF_NOISE_PSK: self._noise_psk or "", + } + if "entry_id" in self.context: + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry is not None + self.hass.config_entries.async_update_entry(entry, data=config_data) + # Reload the config entry to notify of updated config + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + assert self._name is not None return self.async_create_entry( title=self._name, - data={ - CONF_HOST: self._host, - CONF_PORT: self._port, - # The API uses protobuf, so empty string denotes absence - CONF_PASSWORD: self._password or "", - }, + data=config_data, + ) + + async def async_step_encryption_key( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle getting psk for transport encryption.""" + errors = {} + if user_input is not None: + self._noise_psk = user_input[CONF_NOISE_PSK] + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + errors["base"] = error + + return self.async_show_form( + step_id="encryption_key", + data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), + errors=errors, + description_placeholders={"name": self._name}, ) async def async_step_authenticate( @@ -177,7 +254,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def fetch_device_info(self) -> tuple[str | None, DeviceInfo | None]: + async def fetch_device_info(self) -> str | 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 @@ -188,19 +265,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._port, "", zeroconf_instance=zeroconf_instance, + noise_psk=self._noise_psk, ) try: await cli.connect() - device_info = await cli.device_info() - except APIConnectionError as err: - if "resolving" in str(err): - return "resolve_error", None - return "connection_error", None + self._device_info = await cli.device_info() + except RequiresEncryptionAPIError: + return ERROR_REQUIRES_ENCRYPTION_KEY + except InvalidEncryptionKeyAPIError: + return "invalid_psk" + except ResolveAPIError: + return "resolve_error" + except APIConnectionError: + return "connection_error" finally: await cli.disconnect(force=True) - return None, device_info + self._name = self._device_info.name + + return None async def try_login(self) -> str | None: """Try logging in to device and return any errors.""" @@ -213,12 +297,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._port, self._password, zeroconf_instance=zeroconf_instance, + noise_psk=self._noise_psk, ) try: await cli.connect(login=True) - except APIConnectionError: - await cli.disconnect(force=True) + except InvalidAuthAPIError: return "invalid_auth" + except APIConnectionError: + return "connection_error" + finally: + await cli.disconnect(force=True) return None diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 2b926b9b270..51fc18ee37e 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable, cast +from typing import Any, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 9e7f544f610..b8fe4bd74c7 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -247,11 +247,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): ) try_keep_current_mode = False - if self._supports_color_mode and color_modes: - # try the color mode with the least complexity (fewest capabilities set) - # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 - color_modes.sort(key=lambda mode: bin(mode).count("1")) - data["color_mode"] = color_modes[0] if self._supports_color_mode and color_modes: if ( try_keep_current_mode diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a78d2efb763..307227be944 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==8.0.0"], + "requirements": ["aioesphomeapi==9.1.5"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 6d1c9a91e3d..62814f2723b 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,12 +2,14 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips", "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration" }, "step": { "user": { @@ -23,6 +25,18 @@ }, "description": "Please enter the password you set in your configuration for {name}." }, + "encryption_key": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "Please enter the encryption key you set in your configuration for {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key." + }, "discovery_confirm": { "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" diff --git a/homeassistant/components/esphome/translations/ca.json b/homeassistant/components/esphome/translations/ca.json index d0c59194528..4c990994e47 100644 --- a/homeassistant/components/esphome/translations/ca.json +++ b/homeassistant/components/esphome/translations/ca.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs" + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "connection_error": "No s'ha pogut connectar amb ESP. Verifica que l'arxiu YAML cont\u00e9 la l\u00ednia 'api:'.", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_psk": "La clau de xifratge de transport \u00e9s inv\u00e0lida. Assegura't que coincideix amb la de la configuraci\u00f3", "resolve_error": "No s'ha pogut trobar l'adre\u00e7a de l'ESP. Si l'error persisteix, configura una adre\u00e7a IP est\u00e0tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Vols afegir el node `{name}` d'ESPHome a Home Assistant?", "title": "Node d'ESPHome descobert" }, + "encryption_key": { + "data": { + "noise_psk": "Clau de xifrat" + }, + "description": "Introdueix la clau de xifrat de {name} establerta a la configuraci\u00f3." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Clau de xifrat" + }, + "description": "El dispositiu ESPHome {name} ha activat el xifratge de transport o ha canviat la clau de xifrat. Introdueix la clau actualitzada." + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/esphome/translations/cs.json b/homeassistant/components/esphome/translations/cs.json index 9a451a8537f..fc4a7d5bf8c 100644 --- a/homeassistant/components/esphome/translations/cs.json +++ b/homeassistant/components/esphome/translations/cs.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", - "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1" + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "connection_error": "Nelze se p\u0159ipojit k ESP. Zkontrolujte, zda va\u0161e YAML konfigurace obsahuje \u0159\u00e1dek 'api:'.", diff --git a/homeassistant/components/esphome/translations/de.json b/homeassistant/components/esphome/translations/de.json index 8084ef26f0e..6229c09a03e 100644 --- a/homeassistant/components/esphome/translations/de.json +++ b/homeassistant/components/esphome/translations/de.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achte darauf, dass deine YAML-Datei eine Zeile 'api:' enth\u00e4lt.", "invalid_auth": "Ung\u00fcltige Authentifizierung", + "invalid_psk": "Der Transportverschl\u00fcsselungsschl\u00fcssel ist ung\u00fcltig. Bitte stelle sicher, dass es mit deiner Konfiguration \u00fcbereinstimmt", "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, lege eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?", "title": "Gefundener ESPHome-Knoten" }, + "encryption_key": { + "data": { + "noise_psk": "Verschl\u00fcsselungsschl\u00fcssel" + }, + "description": "Bitte gib den Verschl\u00fcsselungsschl\u00fcssel ein, den du in deiner Konfiguration f\u00fcr {name} festgelegt hast." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Verschl\u00fcsselungsschl\u00fcssel" + }, + "description": "Das ESPHome-Ger\u00e4t {name} hat die Transportverschl\u00fcsselung aktiviert oder den Verschl\u00fcsselungscode ge\u00e4ndert. Bitte gib den aktualisierten Schl\u00fcssel ein." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/en.json b/homeassistant/components/esphome/translations/en.json index c57c9d1acb0..5ca5c03f8e9 100644 --- a/homeassistant/components/esphome/translations/en.json +++ b/homeassistant/components/esphome/translations/en.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress" + "already_in_progress": "Configuration flow is already in progress", + "reauth_successful": "Re-authentication was successful" }, "error": { "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", "invalid_auth": "Invalid authentication", + "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration", "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" }, + "encryption_key": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "Please enter the encryption key you set in your configuration for {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Encryption key" + }, + "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/es.json b/homeassistant/components/esphome/translations/es.json index 9c4b3f52406..f7fd73cd227 100644 --- a/homeassistant/components/esphome/translations/es.json +++ b/homeassistant/components/esphome/translations/es.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "ESP ya est\u00e1 configurado", - "already_in_progress": "La configuraci\u00f3n del ESP ya est\u00e1 en marcha" + "already_in_progress": "La configuraci\u00f3n del ESP ya est\u00e1 en marcha", + "reauth_successful": "La re-autenticaci\u00f3n ha funcionado" }, "error": { "connection_error": "No se puede conectar a ESP. Aseg\u00farate de que tu archivo YAML contenga una l\u00ednea 'api:'.", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_psk": "La clave de transporte cifrado no es v\u00e1lida. Por favor, aseg\u00farese de que coincide con la que tiene en su configuraci\u00f3n", "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,6 +23,18 @@ "description": "\u00bfQuieres a\u00f1adir el nodo `{name}` de ESPHome a Home Assistant?", "title": "Nodo ESPHome descubierto" }, + "encryption_key": { + "data": { + "noise_psk": "Clave de cifrado" + }, + "description": "Por favor, introduzca la clave de cifrado que estableci\u00f3 en su configuraci\u00f3n para {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Clave de cifrado" + }, + "description": "El dispositivo ESPHome {name} ha activado el transporte cifrado o ha cambiado la clave de cifrado. Por favor, introduzca la clave actualizada." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/et.json b/homeassistant/components/esphome/translations/et.json index 4f018931141..ea5119b190d 100644 --- a/homeassistant/components/esphome/translations/et.json +++ b/homeassistant/components/esphome/translations/et.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "already_in_progress": "Seadistamine on juba k\u00e4imas" + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "connection_error": "ESP-ga ei saa \u00fchendust luua. Veendu, et YAML-fail sisaldab rida 'api:'.", "invalid_auth": "Tuvastamise viga", + "invalid_psk": "\u00dclekande kr\u00fcpteerimisv\u00f5ti on kehtetu. Veendu, et see vastab seadetes sisalduvale", "resolve_error": "ESP aadressi ei \u00f5nnestu lahendada. Kui see viga p\u00fcsib, m\u00e4\u00e4ra staatiline IP-aadress: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Kas soovid lisada ESPHome'i s\u00f5lme '{name}' Home Assistant-ile?", "title": "Avastastud ESPHome'i s\u00f5lm" }, + "encryption_key": { + "data": { + "noise_psk": "Kr\u00fcptimisv\u00f5ti" + }, + "description": "Sisesta kr\u00fcptimisv\u00f5ti mille m\u00e4\u00e4rasid oma {name} seadetes." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Kr\u00fcptimisv\u00f5ti" + }, + "description": "ESPHome seade {name} lubas \u00fclekande kr\u00fcptimise v\u00f5i muutis kr\u00fcpteerimisv\u00f5tit. Palun sisesta uuendatud v\u00f5ti." + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/esphome/translations/fr.json b/homeassistant/components/esphome/translations/fr.json index 0b977815f6b..9f6f092afff 100644 --- a/homeassistant/components/esphome/translations/fr.json +++ b/homeassistant/components/esphome/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "La configuration ESP est d\u00e9j\u00e0 en cours" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" }, "error": { "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "description": "Veuillez saisir les param\u00e8tres de connexion de votre n\u0153ud [ESPHome] (https://esphomelib.com/)." diff --git a/homeassistant/components/esphome/translations/he.json b/homeassistant/components/esphome/translations/he.json index 5c0f832ba4c..11eaf41ff1a 100644 --- a/homeassistant/components/esphome/translations/he.json +++ b/homeassistant/components/esphome/translations/he.json @@ -2,7 +2,8 @@ "config": { "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" + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "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" @@ -15,6 +16,11 @@ }, "description": "\u05d0\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d4\u05d2\u05d3\u05e8\u05ea \u05d1\u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc\u05da \u05e2\u05d1\u05d5\u05e8 {name}." }, + "encryption_key": { + "data": { + "noise_psk": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05e6\u05e4\u05e0\u05d4" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/esphome/translations/hu.json b/homeassistant/components/esphome/translations/hu.json index 6c4586fbd55..e65577f055e 100644 --- a/homeassistant/components/esphome/translations/hu.json +++ b/homeassistant/components/esphome/translations/hu.json @@ -2,31 +2,45 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van." + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { - "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rlek gy\u0151z\u0151dj meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.", + "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rem, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rlek, \u00e1ll\u00edts be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + "invalid_psk": "Az adat\u00e1tviteli titkos\u00edt\u00e1si kulcs \u00e9rv\u00e9nytelen. K\u00e9rj\u00fck, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy megegyezik a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151vel.", + "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rem, \u00e1ll\u00edtson be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { "password": "Jelsz\u00f3" }, - "description": "K\u00e9rlek, add meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t." + "description": "K\u00e9rem, adja meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t." }, "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` ESPHome csom\u00f3pontot a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni `{name}` ESPHome csom\u00f3pontot Home Assistanthoz?", "title": "Felfedezett ESPHome csom\u00f3pont" }, + "encryption_key": { + "data": { + "noise_psk": "Titkos\u00edt\u00e1si kulcs" + }, + "description": "K\u00e9rj\u00fck, adja meg a {name} konfigur\u00e1ci\u00f3j\u00e1ban be\u00e1ll\u00edtott titkos\u00edt\u00e1si kulcsot." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Titkos\u00edt\u00e1si kulcs" + }, + "description": "{name} ESPHome eszk\u00f6z enged\u00e9lyezte az adat\u00e1tviteli titkos\u00edt\u00e1st vagy megv\u00e1ltoztatta a titkos\u00edt\u00e1si kulcsot. K\u00e9rj\u00fck, adja meg az aktu\u00e1lis kulcsot." + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, - "description": "K\u00e9rlek, add meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontod kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." + "description": "K\u00e9rem, adja meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontj\u00e1nak kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." } } } diff --git a/homeassistant/components/esphome/translations/id.json b/homeassistant/components/esphome/translations/id.json index a39a19e12db..530d86e2f56 100644 --- a/homeassistant/components/esphome/translations/id.json +++ b/homeassistant/components/esphome/translations/id.json @@ -2,14 +2,15 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "already_in_progress": "Alur konfigurasi sedang berlangsung" + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "connection_error": "Tidak dapat terhubung ke ESP. Pastikan file YAML Anda mengandung baris 'api:'.", "invalid_auth": "Autentikasi tidak valid", "resolve_error": "Tidak dapat menemukan alamat ESP. Jika kesalahan ini terus terjadi, atur alamat IP statis: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, - "flow_title": "ESPHome: {name}", + "flow_title": "{name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/translations/it.json b/homeassistant/components/esphome/translations/it.json index 34d1ec78f6e..390054bc345 100644 --- a/homeassistant/components/esphome/translations/it.json +++ b/homeassistant/components/esphome/translations/it.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso" + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "connection_error": "Impossibile connettersi ad ESP. Assicurati che il tuo file YAML contenga una riga \"api:\".", "invalid_auth": "Autenticazione non valida", + "invalid_psk": "La chiave di crittografia del trasporto non \u00e8 valida. Assicurati che corrisponda a ci\u00f2 che hai nella tua configurazione", "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, impostare un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Vuoi aggiungere il nodo ESPHome `{name}` a Home Assistant?", "title": "Trovato nodo ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "Chiave di crittografia" + }, + "description": "Inserisci la chiave di crittografia che hai impostato nella configurazione per {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Chiave di crittografia" + }, + "description": "Il dispositivo ESPHome {name} ha abilitato la crittografia del trasporto o ha modificato la chiave di crittografia. Inserisci la chiave aggiornata." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/nl.json b/homeassistant/components/esphome/translations/nl.json index 019c33004e7..7f6f821104c 100644 --- a/homeassistant/components/esphome/translations/nl.json +++ b/homeassistant/components/esphome/translations/nl.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "already_in_progress": "De configuratiestroom is al begonnen" + "already_in_progress": "De configuratiestroom is al begonnen", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "connection_error": "Kan geen verbinding maken met ESP. Zorg ervoor dat uw YAML-bestand een regel 'api:' bevat.", "invalid_auth": "Ongeldige authenticatie", + "invalid_psk": "De transportcoderingssleutel is ongeldig. Zorg ervoor dat het overeenkomt met wat u in uw configuratie heeft", "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?", "title": "ESPHome node ontdekt" }, + "encryption_key": { + "data": { + "noise_psk": "Coderingssleutel" + }, + "description": "Voer de coderingssleutel in die u in uw configuratie voor {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Coderingssleutel" + }, + "description": "Het ESPHome-apparaat {name} heeft transportcodering ingeschakeld of de coderingssleutel gewijzigd. Voer de bijgewerkte sleutel in." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/no.json b/homeassistant/components/esphome/translations/no.json index 14b92500f41..0d583893570 100644 --- a/homeassistant/components/esphome/translations/no.json +++ b/homeassistant/components/esphome/translations/no.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede" + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.", "invalid_auth": "Ugyldig godkjenning", + "invalid_psk": "Transportkrypteringsn\u00f8kkelen er ugyldig. S\u00f8rg for at den samsvarer med det du har i konfigurasjonen", "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, vennligst [sett en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?", "title": "Oppdaget ESPHome node" }, + "encryption_key": { + "data": { + "noise_psk": "Krypteringsn\u00f8kkel" + }, + "description": "Skriv inn krypteringsn\u00f8kkelen du angav i konfigurasjonen for {name} ." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Krypteringsn\u00f8kkel" + }, + "description": "ESPHome -enheten {name} aktiverte transportkryptering eller endret krypteringsn\u00f8kkelen. Skriv inn den oppdaterte n\u00f8kkelen." + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/esphome/translations/ru.json b/homeassistant/components/esphome/translations/ru.json index 5987a7db13b..8ba4a573cec 100644 --- a/homeassistant/components/esphome/translations/ru.json +++ b/homeassistant/components/esphome/translations/ru.json @@ -2,11 +2,13 @@ "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_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f." + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "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": { "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "invalid_psk": "\u041a\u043b\u044e\u0447 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043e\u043d \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u0432 \u0412\u0430\u0448\u0435\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.", "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips." }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c ESPHome `{name}`?", "title": "ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u041a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f" + }, + "description": "\u0414\u043b\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 {name} \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u043d\u043e\u0433\u043e \u0443\u0440\u043e\u0432\u043d\u044f \u0438\u043b\u0438 \u0438\u0437\u043c\u0435\u043d\u0451\u043d \u043a\u043b\u044e\u0447 \u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044f. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044b\u0439 \u043a\u043b\u044e\u0447." + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/esphome/translations/zh-Hans.json b/homeassistant/components/esphome/translations/zh-Hans.json index b1911b90fde..d0c54f6afb1 100644 --- a/homeassistant/components/esphome/translations/zh-Hans.json +++ b/homeassistant/components/esphome/translations/zh-Hans.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", - "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d" + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" }, "error": { "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002", "invalid_auth": "\u8eab\u4efd\u8ba4\u8bc1\u65e0\u6548", + "invalid_psk": "\u4f20\u8f93\u52a0\u5bc6\u5bc6\u94a5\u65e0\u6548\u3002\u8bf7\u786e\u4fdd\u5b83\u4e0e\u60a8\u7684\u914d\u7f6e\u4e00\u81f4\u3002", "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,9 +23,21 @@ "description": "\u662f\u5426\u8981\u5c06 ESPHome \u8282\u70b9 `{name}` \u6dfb\u52a0\u5230 Home Assistant\uff1f", "title": "\u53d1\u73b0\u4e86 ESPHome \u8282\u70b9" }, + "encryption_key": { + "data": { + "noise_psk": "\u52a0\u5bc6\u5bc6\u94a5" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u8bbe\u5907 {name} \u6240\u8bbe\u7f6e\u7684\u52a0\u5bc6\u5bc6\u94a5\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u52a0\u5bc6\u5bc6\u94a5" + }, + "description": "ESPHome \u8bbe\u5907 {name} \u5df2\u542f\u7528\u6216\u66f4\u6539\u4f20\u8f93\u52a0\u5bc6\u5bc6\u94a5\u3002\u8bf7\u8f93\u5165\u66f4\u65b0\u540e\u7684\u5bc6\u94a5\u4fe1\u606f\u3002" + }, "user": { "data": { - "host": "\u4e3b\u673a", + "host": "\u4e3b\u673a\u5730\u5740", "port": "\u7aef\u53e3" }, "description": "\u8bf7\u8f93\u5165\u60a8\u7684 [ESPHome](https://esphomelib.com/) \u8282\u70b9\u7684\u8fde\u63a5\u8bbe\u7f6e\u3002" diff --git a/homeassistant/components/esphome/translations/zh-Hant.json b/homeassistant/components/esphome/translations/zh-Hant.json index 6ea440c02df..0b415a35c38 100644 --- a/homeassistant/components/esphome/translations/zh-Hant.json +++ b/homeassistant/components/esphome/translations/zh-Hant.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d" + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 ESP\uff0c\u8acb\u78ba\u5b9a\u60a8\u7684 YAML \u6a94\u6848\u5305\u542b\u300capi:\u300d\u8a2d\u5b9a\u5217\u3002", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_psk": "\u50b3\u8f38\u5bc6\u9470\u7121\u6548\u3002\u8acb\u78ba\u5b9a\u8207\u8a2d\u5b9a\u5167\u6240\u8a2d\u5b9a\u4e4b\u5bc6\u9470\u76f8\u7b26\u5408", "resolve_error": "\u7121\u6cd5\u89e3\u6790 ESP \u4f4d\u5740\uff0c\u5047\u5982\u6b64\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u53c3\u8003\u8aaa\u660e\u8a2d\u5b9a\u70ba\u975c\u614b\u56fa\u5b9a IP \uff1a https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -21,6 +23,18 @@ "description": "\u662f\u5426\u8981\u5c07 ESPHome \u7bc0\u9ede `{name}` \u65b0\u589e\u81f3 Home Assistant\uff1f", "title": "\u81ea\u52d5\u63a2\u7d22\u5230 ESPHome \u7bc0\u9ede" }, + "encryption_key": { + "data": { + "noise_psk": "\u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165 {name} \u8a2d\u5b9a\u4e2d\u6240\u8a2d\u5b9a\u4e4b\u5bc6\u9470\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u5bc6\u9470" + }, + "description": "ESPHome \u88dd\u7f6e {name} \u5df2\u958b\u555f\u50b3\u8f38\u52a0\u5bc6\u6216\u8b8a\u66f4\u5bc6\u9470\u3002\u8acb\u8f38\u5165\u66f4\u65b0\u5bc6\u9470\u3002" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/ezviz/translations/ca.json b/homeassistant/components/ezviz/translations/ca.json index c7c71e07122..7c71de300f6 100644 --- a/homeassistant/components/ezviz/translations/ca.json +++ b/homeassistant/components/ezviz/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured_account": "El compte ja ha estat configurat", + "already_configured_account": "El compte ja est\u00e0 configurat", "ezviz_cloud_account_missing": "Falta el compte d'Ezviz cloud. Torna'l a configurar", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/ezviz/translations/fr.json b/homeassistant/components/ezviz/translations/fr.json index 216cf73c7b7..ddce689a2ba 100644 --- a/homeassistant/components/ezviz/translations/fr.json +++ b/homeassistant/components/ezviz/translations/fr.json @@ -15,7 +15,7 @@ "confirm": { "data": { "password": "Mot de passe", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "description": "Entrez les informations d'identification RTSP pour la cam\u00e9ra Ezviz {serial} avec IP {ip_address}", "title": "Cam\u00e9ra Ezviz d\u00e9couverte" @@ -24,7 +24,7 @@ "data": { "password": "Mot de passe", "url": "URL", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "title": "Connectez-vous \u00e0 Ezviz Cloud" }, @@ -32,7 +32,7 @@ "data": { "password": "Mot de passe", "url": "URL", - "username": "Identifiant" + "username": "Nom d'utilisateur" }, "description": "Sp\u00e9cifiez manuellement l'URL de votre r\u00e9gion", "title": "Connectez-vous \u00e0 l'URL Ezviz personnalis\u00e9e" diff --git a/homeassistant/components/ezviz/translations/hu.json b/homeassistant/components/ezviz/translations/hu.json index 3ece0a79dcf..5907f66ceb4 100644 --- a/homeassistant/components/ezviz/translations/hu.json +++ b/homeassistant/components/ezviz/translations/hu.json @@ -8,7 +8,7 @@ "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" + "invalid_host": "\u00c9rv\u00e9nytelen C\u00edm" }, "flow_title": "{serial}", "step": { diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index b96ee24a5bc..9ef55a1f2cc 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,6 +1,12 @@ """Platform for FAA Delays sensor component.""" -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import ATTR_ICON, ATTR_NAME +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, FAA_BINARY_SENSORS @@ -10,83 +16,68 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up a FAA sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - binary_sensors = [] - for kind, attrs in FAA_BINARY_SENSORS.items(): - name = attrs[ATTR_NAME] - icon = attrs[ATTR_ICON] + entities = [ + FAABinarySensor(coordinator, entry.entry_id, description) + for description in FAA_BINARY_SENSORS + ] - binary_sensors.append( - FAABinarySensor(coordinator, kind, name, icon, entry.entry_id) - ) - - async_add_entities(binary_sensors) + async_add_entities(entities) class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): """Define a binary sensor for FAA Delays.""" - def __init__(self, coordinator, sensor_type, name, icon, entry_id): + def __init__( + self, coordinator, entry_id, description: BinarySensorEntityDescription + ): """Initialize the sensor.""" super().__init__(coordinator) + self.entity_description = description self.coordinator = coordinator self._entry_id = entry_id - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._id = self.coordinator.data.iata - self._attrs = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._id} {self._name}" - - @property - def icon(self): - """Return the icon.""" - return self._icon + self._attrs: dict[str, Any] = {} + _id = coordinator.data.iata + self._attr_name = f"{_id} {description.name}" + self._attr_unique_id = f"{_id}_{description.key}" @property def is_on(self): """Return the status of the sensor.""" - if self._sensor_type == "GROUND_DELAY": + sensor_type = self.entity_description.key + if sensor_type == "GROUND_DELAY": return self.coordinator.data.ground_delay.status - if self._sensor_type == "GROUND_STOP": + if sensor_type == "GROUND_STOP": return self.coordinator.data.ground_stop.status - if self._sensor_type == "DEPART_DELAY": + if sensor_type == "DEPART_DELAY": return self.coordinator.data.depart_delay.status - if self._sensor_type == "ARRIVE_DELAY": + if sensor_type == "ARRIVE_DELAY": return self.coordinator.data.arrive_delay.status - if self._sensor_type == "CLOSURE": + if sensor_type == "CLOSURE": return self.coordinator.data.closure.status return None - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._id}_{self._sensor_type}" - @property def extra_state_attributes(self): """Return attributes for sensor.""" - if self._sensor_type == "GROUND_DELAY": + sensor_type = self.entity_description.key + if sensor_type == "GROUND_DELAY": self._attrs["average"] = self.coordinator.data.ground_delay.average self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif self._sensor_type == "GROUND_STOP": + elif sensor_type == "GROUND_STOP": self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif self._sensor_type == "DEPART_DELAY": + elif sensor_type == "DEPART_DELAY": self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum self._attrs["trend"] = self.coordinator.data.depart_delay.trend self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif self._sensor_type == "ARRIVE_DELAY": + elif sensor_type == "ARRIVE_DELAY": self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum self._attrs["trend"] = self.coordinator.data.arrive_delay.trend self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif self._sensor_type == "CLOSURE": + elif sensor_type == "CLOSURE": self._attrs["begin"] = self.coordinator.data.closure.begin self._attrs["end"] = self.coordinator.data.closure.end self._attrs["reason"] = self.coordinator.data.closure.reason diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py index c725be88106..f7ee8e7bad8 100644 --- a/homeassistant/components/faa_delays/const.py +++ b/homeassistant/components/faa_delays/const.py @@ -1,28 +1,34 @@ """Constants for the FAA Delays integration.""" +from __future__ import annotations -from homeassistant.const import ATTR_ICON, ATTR_NAME +from homeassistant.components.binary_sensor import BinarySensorEntityDescription DOMAIN = "faa_delays" -FAA_BINARY_SENSORS = { - "GROUND_DELAY": { - ATTR_NAME: "Ground Delay", - ATTR_ICON: "mdi:airport", - }, - "GROUND_STOP": { - ATTR_NAME: "Ground Stop", - ATTR_ICON: "mdi:airport", - }, - "DEPART_DELAY": { - ATTR_NAME: "Departure Delay", - ATTR_ICON: "mdi:airplane-takeoff", - }, - "ARRIVE_DELAY": { - ATTR_NAME: "Arrival Delay", - ATTR_ICON: "mdi:airplane-landing", - }, - "CLOSURE": { - ATTR_NAME: "Closure", - ATTR_ICON: "mdi:airplane:off", - }, -} +FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="GROUND_DELAY", + name="Ground Delay", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="GROUND_STOP", + name="Ground Stop", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="DEPART_DELAY", + name="Departure Delay", + icon="mdi:airplane-takeoff", + ), + BinarySensorEntityDescription( + key="ARRIVE_DELAY", + name="Arrival Delay", + icon="mdi:airplane-landing", + ), + BinarySensorEntityDescription( + key="CLOSURE", + name="Closure", + icon="mdi:airplane:off", + ), +) diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 38cfb33b42d..503aaaac52a 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -36,7 +39,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/fan/translations/he.json b/homeassistant/components/fan/translations/he.json index db876480dfc..92e38a79918 100644 --- a/homeassistant/components/fan/translations/he.json +++ b/homeassistant/components/fan/translations/he.json @@ -1,8 +1,16 @@ { "device_automation": { + "action_type": { + "turn_off": "\u05db\u05d9\u05d1\u05d5\u05d9 {entity_name}", + "turn_on": "\u05d4\u05e4\u05e2\u05dc\u05ea {entity_name}" + }, "condition_type": { "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", "is_on": "{entity_name} \u05e4\u05d5\u05e2\u05dc" + }, + "trigger_type": { + "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", + "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc" } }, "state": { diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 58fde1e370b..e72eb7762d6 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -136,7 +136,7 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): "value" in device.properties or "heatingThermostatSetpoint" in device.properties ) - and (device.properties.unit == "C" or device.properties.unit == "F") + and device.properties.unit in ("C", "F") ): self._temp_sensor_device = FibaroDevice(device) tempunit = device.properties.unit diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 0e61b580902..6964abd9b3d 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -44,13 +44,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="fido_dollar", name="Fido dollar", native_unit_of_measurement=PRICE, - icon="mdi:cash-usd", + icon="mdi:cash", ), SensorEntityDescription( key="balance", name="Balance", native_unit_of_measurement=PRICE, - icon="mdi:cash-usd", + icon="mdi:cash", ), SensorEntityDescription( key="data_used", diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index f9705887549..665ef6b6ecd 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import history from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, @@ -191,6 +192,7 @@ class SensorFilter(SensorEntity): self._filters = filters self._icon = None self._device_class = None + self._attr_state_class = None @callback def _update_filter_sensor_state_event(self, event): @@ -248,6 +250,9 @@ class SensorFilter(SensorEntity): ): self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + if self._attr_state_class is None: + self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) + if self._unit_of_measurement is None: self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/fireservicerota/translations/ca.json b/homeassistant/components/fireservicerota/translations/ca.json index 287bb81e51e..261350db3f8 100644 --- a/homeassistant/components/fireservicerota/translations/ca.json +++ b/homeassistant/components/fireservicerota/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "create_entry": { diff --git a/homeassistant/components/fireservicerota/translations/fr.json b/homeassistant/components/fireservicerota/translations/fr.json index fdbf28e32e1..477e8df621c 100644 --- a/homeassistant/components/fireservicerota/translations/fr.json +++ b/homeassistant/components/fireservicerota/translations/fr.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Le compte \u00e0 d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { - "default": "Autentification r\u00e9ussie" + "default": "Authentification r\u00e9ussie" }, "error": { - "invalid_auth": "Autentification invalide" + "invalid_auth": "Authentification invalide" }, "step": { "reauth": { @@ -21,7 +21,7 @@ "data": { "password": "Mot de passe", "url": "Site web", - "username": "Utilisateur" + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/firmata/pin.py b/homeassistant/components/firmata/pin.py index af07871efc7..6dadb07fd63 100644 --- a/homeassistant/components/firmata/pin.py +++ b/homeassistant/components/firmata/pin.py @@ -1,6 +1,8 @@ """Code to handle pins on a Firmata board.""" +from __future__ import annotations + +from collections.abc import Callable import logging -from typing import Callable from .board import FirmataBoard, FirmataPinType from .const import PIN_MODE_INPUT, PIN_MODE_PULLUP, PIN_TYPE_ANALOG diff --git a/homeassistant/components/firmata/translations/fr.json b/homeassistant/components/firmata/translations/fr.json index b3509c9126c..ea09d58c354 100644 --- a/homeassistant/components/firmata/translations/fr.json +++ b/homeassistant/components/firmata/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "cannot_connect": "Impossible de se connecter \u00e0 la carte Firmata pendant la configuration" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "one": "Vide ", diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index e5891758f60..1da3058c790 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,8 +1,10 @@ """Constants for the Fitbit platform.""" from __future__ import annotations +from dataclasses import dataclass from typing import Final +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -43,66 +45,230 @@ DEFAULT_CONFIG: Final[dict[str, str]] = { } DEFAULT_CLOCK_FORMAT: Final = "24H" -FITBIT_RESOURCES_LIST: Final[dict[str, tuple[str, str | None, str]]] = { - "activities/activityCalories": ("Activity Calories", "cal", "fire"), - "activities/calories": ("Calories", "cal", "fire"), - "activities/caloriesBMR": ("Calories BMR", "cal", "fire"), - "activities/distance": ("Distance", "", "map-marker"), - "activities/elevation": ("Elevation", "", "walk"), - "activities/floors": ("Floors", "floors", "walk"), - "activities/heart": ("Resting Heart Rate", "bpm", "heart-pulse"), - "activities/minutesFairlyActive": ("Minutes Fairly Active", TIME_MINUTES, "walk"), - "activities/minutesLightlyActive": ("Minutes Lightly Active", TIME_MINUTES, "walk"), - "activities/minutesSedentary": ( - "Minutes Sedentary", - TIME_MINUTES, - "seat-recline-normal", + +@dataclass +class FitbitRequiredKeysMixin: + """Mixin for required keys.""" + + unit_type: str | None + + +@dataclass +class FitbitSensorEntityDescription(SensorEntityDescription, FitbitRequiredKeysMixin): + """Describes Fitbit sensor entity.""" + + +FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( + FitbitSensorEntityDescription( + key="activities/activityCalories", + name="Activity Calories", + unit_type="cal", + icon="mdi:fire", ), - "activities/minutesVeryActive": ("Minutes Very Active", TIME_MINUTES, "run"), - "activities/steps": ("Steps", "steps", "walk"), - "activities/tracker/activityCalories": ("Tracker Activity Calories", "cal", "fire"), - "activities/tracker/calories": ("Tracker Calories", "cal", "fire"), - "activities/tracker/distance": ("Tracker Distance", "", "map-marker"), - "activities/tracker/elevation": ("Tracker Elevation", "", "walk"), - "activities/tracker/floors": ("Tracker Floors", "floors", "walk"), - "activities/tracker/minutesFairlyActive": ( - "Tracker Minutes Fairly Active", - TIME_MINUTES, - "walk", + FitbitSensorEntityDescription( + key="activities/calories", + name="Calories", + unit_type="cal", + icon="mdi:fire", ), - "activities/tracker/minutesLightlyActive": ( - "Tracker Minutes Lightly Active", - TIME_MINUTES, - "walk", + FitbitSensorEntityDescription( + key="activities/caloriesBMR", + name="Calories BMR", + unit_type="cal", + icon="mdi:fire", ), - "activities/tracker/minutesSedentary": ( - "Tracker Minutes Sedentary", - TIME_MINUTES, - "seat-recline-normal", + FitbitSensorEntityDescription( + key="activities/distance", + name="Distance", + unit_type="", + icon="mdi:map-marker", ), - "activities/tracker/minutesVeryActive": ( - "Tracker Minutes Very Active", - TIME_MINUTES, - "run", + FitbitSensorEntityDescription( + key="activities/elevation", + name="Elevation", + unit_type="", + icon="mdi:walk", ), - "activities/tracker/steps": ("Tracker Steps", "steps", "walk"), - "body/bmi": ("BMI", "BMI", "human"), - "body/fat": ("Body Fat", PERCENTAGE, "human"), - "body/weight": ("Weight", "", "human"), - "devices/battery": ("Battery", None, "battery"), - "sleep/awakeningsCount": ("Awakenings Count", "times awaken", "sleep"), - "sleep/efficiency": ("Sleep Efficiency", PERCENTAGE, "sleep"), - "sleep/minutesAfterWakeup": ("Minutes After Wakeup", TIME_MINUTES, "sleep"), - "sleep/minutesAsleep": ("Sleep Minutes Asleep", TIME_MINUTES, "sleep"), - "sleep/minutesAwake": ("Sleep Minutes Awake", TIME_MINUTES, "sleep"), - "sleep/minutesToFallAsleep": ( - "Sleep Minutes to Fall Asleep", - TIME_MINUTES, - "sleep", + FitbitSensorEntityDescription( + key="activities/floors", + name="Floors", + unit_type="floors", + icon="mdi:walk", ), - "sleep/startTime": ("Sleep Start Time", None, "clock"), - "sleep/timeInBed": ("Sleep Time in Bed", TIME_MINUTES, "hotel"), -} + FitbitSensorEntityDescription( + key="activities/heart", + name="Resting Heart Rate", + unit_type="bpm", + icon="mdi:heart-pulse", + ), + FitbitSensorEntityDescription( + key="activities/minutesFairlyActive", + name="Minutes Fairly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/minutesLightlyActive", + name="Minutes Lightly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/minutesSedentary", + name="Minutes Sedentary", + unit_type=TIME_MINUTES, + icon="mdi:seat-recline-normal", + ), + FitbitSensorEntityDescription( + key="activities/minutesVeryActive", + name="Minutes Very Active", + unit_type=TIME_MINUTES, + icon="mdi:run", + ), + FitbitSensorEntityDescription( + key="activities/steps", + name="Steps", + unit_type="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/activityCalories", + name="Tracker Activity Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/calories", + name="Tracker Calories", + unit_type="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/distance", + name="Tracker Distance", + unit_type="", + icon="mdi:map-marker", + ), + FitbitSensorEntityDescription( + key="activities/tracker/elevation", + name="Tracker Elevation", + unit_type="", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/floors", + name="Tracker Floors", + unit_type="floors", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesFairlyActive", + name="Tracker Minutes Fairly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesLightlyActive", + name="Tracker Minutes Lightly Active", + unit_type=TIME_MINUTES, + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesSedentary", + name="Tracker Minutes Sedentary", + unit_type=TIME_MINUTES, + icon="mdi:seat-recline-normal", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesVeryActive", + name="Tracker Minutes Very Active", + unit_type=TIME_MINUTES, + icon="mdi:run", + ), + FitbitSensorEntityDescription( + key="activities/tracker/steps", + name="Tracker Steps", + unit_type="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="body/bmi", + name="BMI", + unit_type="BMI", + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="body/fat", + name="Body Fat", + unit_type=PERCENTAGE, + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="body/weight", + name="Weight", + unit_type="", + icon="mdi:human", + ), + FitbitSensorEntityDescription( + key="sleep/awakeningsCount", + name="Awakenings Count", + unit_type="times awaken", + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/efficiency", + name="Sleep Efficiency", + unit_type=PERCENTAGE, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAfterWakeup", + name="Minutes After Wakeup", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAsleep", + name="Sleep Minutes Asleep", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesAwake", + name="Sleep Minutes Awake", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/minutesToFallAsleep", + name="Sleep Minutes to Fall Asleep", + unit_type=TIME_MINUTES, + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + unit_type=None, + icon="mdi:clock", + ), + FitbitSensorEntityDescription( + key="sleep/timeInBed", + name="Sleep Time in Bed", + unit_type=TIME_MINUTES, + icon="mdi:hotel", + ), +) + +FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( + key="devices/battery", + name="Battery", + unit_type=None, + icon="mdi:battery", +) + +FITBIT_RESOURCES_KEYS: Final[list[str]] = [ + desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) +] FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { "en_US": { diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 0bd4ed36199..34c4f61f554 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -48,7 +48,10 @@ from .const import ( FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, FITBIT_MEASUREMENTS, + FITBIT_RESOURCE_BATTERY, + FITBIT_RESOURCES_KEYS, FITBIT_RESOURCES_LIST, + FitbitSensorEntityDescription, ) _LOGGER: Final = logging.getLogger(__name__) @@ -61,7 +64,7 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional( CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES - ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), + ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]), vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( ["12H", "24H"] ), @@ -188,8 +191,7 @@ def setup_platform( if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() - unit_system = config.get(CONF_UNIT_SYSTEM) - if unit_system == "default": + if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": authd_client.system = authd_client.user_profile_get()["user"]["locale"] if authd_client.system != "en_GB": if hass.config.units.is_metric: @@ -199,35 +201,35 @@ def setup_platform( else: authd_client.system = unit_system - dev = [] registered_devs = authd_client.get_devices() - clock_format = config.get(CONF_CLOCK_FORMAT, DEFAULT_CLOCK_FORMAT) - for resource in config.get(CONF_MONITORED_RESOURCES, FITBIT_DEFAULT_RESOURCES): - - # monitor battery for all linked FitBit devices - if resource == "devices/battery": - for dev_extra in registered_devs: - dev.append( - FitbitSensor( - authd_client, - config_path, - resource, - hass.config.units.is_metric, - clock_format, - dev_extra, - ) - ) - else: - dev.append( + clock_format = config[CONF_CLOCK_FORMAT] + monitored_resources = config[CONF_MONITORED_RESOURCES] + entities = [ + FitbitSensor( + authd_client, + config_path, + description, + hass.config.units.is_metric, + clock_format, + ) + for description in FITBIT_RESOURCES_LIST + if description.key in monitored_resources + ] + if "devices/battery" in monitored_resources: + entities.extend( + [ FitbitSensor( authd_client, config_path, - resource, + FITBIT_RESOURCE_BATTERY, hass.config.units.is_metric, clock_format, + dev_extra, ) - ) - add_entities(dev, True) + for dev_extra in registered_devs + ] + ) + add_entities(entities, True) else: oauth = FitbitOauth2Client( @@ -335,28 +337,28 @@ class FitbitAuthCallbackView(HomeAssistantView): class FitbitSensor(SensorEntity): """Implementation of a Fitbit sensor.""" + entity_description: FitbitSensorEntityDescription + def __init__( self, client: Fitbit, config_path: str, - resource_type: str, + description: FitbitSensorEntityDescription, is_metric: bool, clock_format: str, extra: dict[str, str] | None = None, ) -> None: """Initialize the Fitbit sensor.""" + self.entity_description = description self.client = client self.config_path = config_path - self.resource_type = resource_type self.is_metric = is_metric self.clock_format = clock_format self.extra = extra - self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] if self.extra is not None: - self._name = f"{self.extra.get('deviceVersion')} Battery" - unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] - if unit_type == "": - split_resource = self.resource_type.split("/") + self._attr_name = f"{self.extra.get('deviceVersion')} Battery" + if (unit_type := description.unit_type) == "": + split_resource = description.key.rsplit("/", maxsplit=1)[-1] try: measurement_system = FITBIT_MEASUREMENTS[self.client.system] except KeyError: @@ -364,43 +366,24 @@ class FitbitSensor(SensorEntity): measurement_system = FITBIT_MEASUREMENTS["metric"] else: measurement_system = FITBIT_MEASUREMENTS["en_US"] - unit_type = measurement_system[split_resource[-1]] - self._unit_of_measurement = unit_type - self._state: str | None = None + unit_type = measurement_system[split_resource] + self._attr_native_unit_of_measurement = unit_type @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self) -> str: + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if self.resource_type == "devices/battery" and self.extra is not None: + if self.entity_description.key == "devices/battery" and self.extra is not None: extra_battery = self.extra.get("battery") if extra_battery is not None: battery_level = BATTERY_LEVELS.get(extra_battery) if battery_level is not None: return icon_for_battery_level(battery_level=battery_level) - fitbit_ressource = FITBIT_RESOURCES_LIST[self.resource_type] - return f"mdi:{fitbit_ressource[2]}" + return self.entity_description.icon @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs: dict[str, str | None] = {} - - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + attrs: dict[str, str | None] = {ATTR_ATTRIBUTION: ATTRIBUTION} if self.extra is not None: attrs["model"] = self.extra.get("deviceVersion") @@ -411,31 +394,32 @@ class FitbitSensor(SensorEntity): def update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" - if self.resource_type == "devices/battery" and self.extra is not None: + resource_type = self.entity_description.key + if resource_type == "devices/battery" and self.extra is not None: registered_devs: list[dict[str, Any]] = self.client.get_devices() device_id = self.extra.get("id") self.extra = list( filter(lambda device: device.get("id") == device_id, registered_devs) )[0] - self._state = self.extra.get("battery") + self._attr_native_value = self.extra.get("battery") else: - container = self.resource_type.replace("/", "-") - response = self.client.time_series(self.resource_type, period="7d") + container = resource_type.replace("/", "-") + response = self.client.time_series(resource_type, period="7d") raw_state = response[container][-1].get("value") - if self.resource_type == "activities/distance": - self._state = format(float(raw_state), ".2f") - elif self.resource_type == "activities/tracker/distance": - self._state = format(float(raw_state), ".2f") - elif self.resource_type == "body/bmi": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "body/fat": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "body/weight": - self._state = format(float(raw_state), ".1f") - elif self.resource_type == "sleep/startTime": + if resource_type == "activities/distance": + self._attr_native_value = format(float(raw_state), ".2f") + elif resource_type == "activities/tracker/distance": + self._attr_native_value = format(float(raw_state), ".2f") + elif resource_type == "body/bmi": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "body/fat": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "body/weight": + self._attr_native_value = format(float(raw_state), ".1f") + elif resource_type == "sleep/startTime": if raw_state == "": - self._state = "-" + self._attr_native_value = "-" elif self.clock_format == "12H": hours, minutes = raw_state.split(":") hours, minutes = int(hours), int(minutes) @@ -445,20 +429,22 @@ class FitbitSensor(SensorEntity): hours -= 12 elif hours == 0: hours = 12 - self._state = f"{hours}:{minutes:02d} {setting}" + self._attr_native_value = f"{hours}:{minutes:02d} {setting}" else: - self._state = raw_state + self._attr_native_value = raw_state else: if self.is_metric: - self._state = raw_state + self._attr_native_value = raw_state else: try: - self._state = f"{int(raw_state):,}" + self._attr_native_value = f"{int(raw_state):,}" except TypeError: - self._state = raw_state + self._attr_native_value = raw_state - if self.resource_type == "activities/heart": - self._state = response[container][-1].get("value").get("restingHeartRate") + if resource_type == "activities/heart": + self._attr_native_value = ( + response[container][-1].get("value").get("restingHeartRate") + ) token = self.client.client.session.token config_contents = { diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 9d635e3bf7f..ac22e788a6e 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -1,10 +1,10 @@ """The Fjäråskupan integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Callable from bleak import BleakScanner from bleak.backends.device import BLEDevice diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 2484a0d9bc2..9af93eaf9c0 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -1,8 +1,8 @@ """Support for sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from fjaraskupan import Device, State diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json index 68158776afe..d9cd5640848 100644 --- a/homeassistant/components/fjaraskupan/manifest.json +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", "requirements": [ - "fjaraskupan==1.0.0" + "fjaraskupan==1.0.1" ], "codeowners": [ "@elupus" diff --git a/homeassistant/components/fjaraskupan/translations/cs.json b/homeassistant/components/fjaraskupan/translations/cs.json new file mode 100644 index 00000000000..3f0012e00d2 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/el.json b/homeassistant/components/fjaraskupan/translations/el.json new file mode 100644 index 00000000000..cb6a9ccddb2 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/el.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0394\u03b5\u03bd \u03b2\u03c1\u03ad\u03b8\u03b7\u03ba\u03b5 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03bf" + }, + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {integration};" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/es.json b/homeassistant/components/fjaraskupan/translations/es.json new file mode 100644 index 00000000000..36ff1884048 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/es.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/fr.json b/homeassistant/components/fjaraskupan/translations/fr.json new file mode 100644 index 00000000000..4239f4eff7f --- /dev/null +++ b/homeassistant/components/fjaraskupan/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 configurer Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/hu.json b/homeassistant/components/fjaraskupan/translations/hu.json new file mode 100644 index 00000000000..cb7e29986ac --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva. Csak egyetlen konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a Fj\u00e4r\u00e5skupan szolg\u00e1ltat\u00e1st?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/id.json b/homeassistant/components/fjaraskupan/translations/id.json new file mode 100644 index 00000000000..ed64894fff4 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/id.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "confirm": { + "description": "Ingin menyiapkan Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/it.json b/homeassistant/components/fjaraskupan/translations/it.json new file mode 100644 index 00000000000..49b68f1f9a8 --- /dev/null +++ b/homeassistant/components/fjaraskupan/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 impostare Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json index a20b5059ef7..cb8382539b4 100644 --- a/homeassistant/components/flick_electric/strings.json +++ b/homeassistant/components/flick_electric/strings.json @@ -6,8 +6,8 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "Client ID (Optional)", - "client_secret": "Client Secret (Optional)" + "client_id": "Client ID (optional)", + "client_secret": "Client Secret (optional)" } } }, diff --git a/homeassistant/components/flick_electric/translations/ca.json b/homeassistant/components/flick_electric/translations/ca.json index 74fd0e79708..b98cfc742db 100644 --- a/homeassistant/components/flick_electric/translations/ca.json +++ b/homeassistant/components/flick_electric/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/flick_electric/translations/en.json b/homeassistant/components/flick_electric/translations/en.json index ecade0c677b..9fdef5dd01d 100644 --- a/homeassistant/components/flick_electric/translations/en.json +++ b/homeassistant/components/flick_electric/translations/en.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "Client ID (Optional)", - "client_secret": "Client Secret (Optional)", + "client_id": "Client ID (optional)", + "client_secret": "Client Secret (optional)", "password": "Password", "username": "Username" }, diff --git a/homeassistant/components/flick_electric/translations/fr.json b/homeassistant/components/flick_electric/translations/fr.json index 291a8e59fe4..fc7605c0975 100644 --- a/homeassistant/components/flick_electric/translations/fr.json +++ b/homeassistant/components/flick_electric/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/flick_electric/translations/he.json b/homeassistant/components/flick_electric/translations/he.json index b1e5464047b..0cbd1ab331b 100644 --- a/homeassistant/components/flick_electric/translations/he.json +++ b/homeassistant/components/flick_electric/translations/he.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "Client ID (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", - "client_secret": "Client Secret (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "client_id": "\u05de\u05d6\u05d4\u05d4 \u05dc\u05e7\u05d5\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "client_secret": "\u05e1\u05d5\u05d3 \u05dc\u05e7\u05d5\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } diff --git a/homeassistant/components/flick_electric/translations/id.json b/homeassistant/components/flick_electric/translations/id.json index 8c283cfd56e..3085534a862 100644 --- a/homeassistant/components/flick_electric/translations/id.json +++ b/homeassistant/components/flick_electric/translations/id.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "client_id": "ID Klien (Opsional)", - "client_secret": "Kode Rahasia Klien (Opsional)", + "client_id": "ID Klien (opsional)", + "client_secret": "Kode Rahasia Klien (opsional)", "password": "Kata Sandi", "username": "Nama Pengguna" }, diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 66ea93484f7..fd7c3f5c02a 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -5,21 +5,22 @@ import logging from flipr_api import FliprAPIRestClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import ATTR_ATTRIBUTION, CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=60) -PLATFORMS = ["sensor"] +PLATFORMS = ["binary_sensor", "sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -75,14 +76,22 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): class FliprEntity(CoordinatorEntity): """Implements a common class elements representing the Flipr component.""" - def __init__(self, coordinator, flipr_id, info_type): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: """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 + self.entity_description = description + if coordinator.config_entry: + flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] + self._attr_unique_id = f"{flipr_id}-{description.key}" + + self._attr_device_info = { + "identifiers": {(DOMAIN, flipr_id)}, + "name": NAME, + "manufacturer": MANUFACTURER, + } + + self._attr_name = f"Flipr {flipr_id} {description.name}" diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py new file mode 100644 index 00000000000..400c1562088 --- /dev/null +++ b/homeassistant/components/flipr/binary_sensor.py @@ -0,0 +1,46 @@ +"""Support for Flipr binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) + +from . import FliprEntity +from .const import DOMAIN + +BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="ph_status", + name="PH Status", + device_class=DEVICE_CLASS_PROBLEM, + ), + BinarySensorEntityDescription( + key="chlorine_status", + name="Chlorine Status", + device_class=DEVICE_CLASS_PROBLEM, + ), +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup of flipr binary sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FliprBinarySensor(coordinator, description) + for description in BINARY_SENSORS_TYPES + ) + + +class FliprBinarySensor(FliprEntity, BinarySensorEntity): + """Representation of Flipr binary sensors.""" + + @property + def is_on(self): + """Return true if the binary sensor is on in case of a Problem is detected.""" + return ( + self.coordinator.data[self.entity_description.key] == "TooLow" + or self.coordinator.data[self.entity_description.key] == "TooHigh" + ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index f9fd4e9633e..e79ba131618 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -1,9 +1,10 @@ """Sensor platform for the Flipr's pool_sensor.""" +from __future__ import annotations + from datetime import datetime -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - ATTR_ATTRIBUTION, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, ELECTRIC_POTENTIAL_MILLIVOLT, @@ -11,81 +12,55 @@ from homeassistant.const import ( ) from . import FliprEntity -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN +from .const import DOMAIN -SENSORS = { - "chlorine": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "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": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Red OX", - "device_class": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="chlorine", + name="Chlorine", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + ), + SensorEntityDescription( + key="ph", + name="pH", + icon="mdi:pool", + ), + SensorEntityDescription( + key="temperature", + name="Water Temp", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="date_time", + name="Last Measured", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key="red_ox", + name="Red OX", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + ), +) 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) + sensors = [FliprSensor(coordinator, description) for description in SENSOR_TYPES] + async_add_entities(sensors) class FliprSensor(FliprEntity, SensorEntity): """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 native_value(self): """State of the sensor.""" - state = self.coordinator.data[self.info_type] + state = self.coordinator.data[self.entity_description.key] 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 native_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/translations/es.json b/homeassistant/components/flipr/translations/es.json index 766f83856ec..0a066451b84 100644 --- a/homeassistant/components/flipr/translations/es.json +++ b/homeassistant/components/flipr/translations/es.json @@ -1,21 +1,26 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "no_flipr_id_found": "Por ahora no hay ning\u00fan ID de Flipr asociado a tu cuenta. Deber\u00edas verificar que est\u00e1 funcionando con la aplicaci\u00f3n m\u00f3vil de Flipr primero.", - "unknown": "Error desconocido" + "unknown": "Error inesperado" }, "step": { "flipr_id": { "data": { "flipr_id": "ID de Flipr" }, - "description": "Elija su ID de Flipr en la lista", + "description": "Elige tu ID de Flipr en la lista", "title": "Elige tu Flipr" }, "user": { "data": { - "email": "Correo-e", - "password": "Clave" + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" }, "description": "Con\u00e9ctese usando su cuenta Flipr.", "title": "Conectarse a Flipr" diff --git a/homeassistant/components/flipr/translations/id.json b/homeassistant/components/flipr/translations/id.json new file mode 100644 index 00000000000..63751867097 --- /dev/null +++ b/homeassistant/components/flipr/translations/id.json @@ -0,0 +1,20 @@ +{ + "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": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/hu.json b/homeassistant/components/flo/translations/hu.json index 0abcc301f0c..9590d3c12be 100644 --- a/homeassistant/components/flo/translations/hu.json +++ b/homeassistant/components/flo/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index a7bb9fbd3c8..5060bd96489 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -1,4 +1,8 @@ """The Flume component.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription + DOMAIN = "flume" PLATFORMS = ["sensor"] @@ -6,15 +10,43 @@ PLATFORMS = ["sensor"] DEFAULT_NAME = "Flume Sensor" FLUME_TYPE_SENSOR = 2 -FLUME_QUERIES_SENSOR = { - "current_interval": {"friendly_name": "Current", "unit_of_measurement": "gal/m"}, - "month_to_date": {"friendly_name": "Current Month", "unit_of_measurement": "gal"}, - "week_to_date": {"friendly_name": "Current Week", "unit_of_measurement": "gal"}, - "today": {"friendly_name": "Current Day", "unit_of_measurement": "gal"}, - "last_60_min": {"friendly_name": "60 Minutes", "unit_of_measurement": "gal/h"}, - "last_24_hrs": {"friendly_name": "24 Hours", "unit_of_measurement": "gal/d"}, - "last_30_days": {"friendly_name": "30 Days", "unit_of_measurement": "gal/mo"}, -} +FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_interval", + name="Current", + native_unit_of_measurement="gal/m", + ), + SensorEntityDescription( + key="month_to_date", + name="Current Month", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="week_to_date", + name="Current Week", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="today", + name="Current Day", + native_unit_of_measurement="gal", + ), + SensorEntityDescription( + key="last_60_min", + name="60 Minutes", + native_unit_of_measurement="gal/h", + ), + SensorEntityDescription( + key="last_24_hrs", + name="24 Hours", + native_unit_of_measurement="gal/d", + ), + SensorEntityDescription( + key="last_30_days", + name="30 Days", + native_unit_of_measurement="gal/mo", + ), +) FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index d689f5fb17f..cdad0dd3f0c 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -2,7 +2,7 @@ "domain": "flume", "name": "Flume", "documentation": "https://www.home-assistant.io/integrations/flume/", - "requirements": ["pyflume==0.5.5"], + "requirements": ["pyflume==0.6.5"], "codeowners": ["@ChrisMandich", "@bdraco"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index ee67a863be6..ff4610ca788 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -6,7 +6,11 @@ from numbers import Number from pyflume import FlumeData import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_CLIENT_ID, @@ -93,16 +97,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = _create_flume_device_coordinator(hass, flume_device) - for flume_query_sensor in FLUME_QUERIES_SENSOR.items(): - flume_entity_list.append( + flume_entity_list.extend( + [ FlumeSensor( coordinator, flume_device, - flume_query_sensor, - f"{device_friendly_name} {flume_query_sensor[1]['friendly_name']}", + device_friendly_name, device_id, + description, ) - ) + for description in FLUME_QUERIES_SENSOR + ] + ) if flume_entity_list: async_add_entities(flume_entity_list) @@ -111,50 +117,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FlumeSensor(CoordinatorEntity, SensorEntity): """Representation of the Flume sensor.""" - def __init__(self, coordinator, flume_device, flume_query_sensor, name, device_id): + def __init__( + self, + coordinator, + flume_device, + name, + device_id, + description: SensorEntityDescription, + ): """Initialize the Flume sensor.""" super().__init__(coordinator) + self.entity_description = description self._flume_device = flume_device - self._flume_query_sensor = flume_query_sensor - self._name = name - self._device_id = device_id - self._state = None - @property - def device_info(self): - """Device info for the flume sensor.""" - return { - "name": self._name, - "identifiers": {(DOMAIN, self._device_id)}, + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{description.key}_{device_id}" + self._attr_device_info = { + "name": self.name, + "identifiers": {(DOMAIN, device_id)}, "manufacturer": "Flume, Inc.", "model": "Flume Smart Water Monitor", } - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def native_value(self): """Return the state of the sensor.""" - sensor_key = self._flume_query_sensor[0] + sensor_key = self.entity_description.key if sensor_key not in self._flume_device.values: return None return _format_state_value(self._flume_device.values[sensor_key]) - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - # This is in gallons per SCAN_INTERVAL - return self._flume_query_sensor[1]["unit_of_measurement"] - - @property - def unique_id(self): - """Flume query and Device unique ID.""" - return f"{self._flume_query_sensor[0]}_{self._device_id}" - async def async_added_to_hass(self): """Request an update when added.""" await super().async_added_to_hass() diff --git a/homeassistant/components/flume/translations/ca.json b/homeassistant/components/flume/translations/ca.json index 04a7accf4a5..5cd81a00a67 100644 --- a/homeassistant/components/flume/translations/ca.json +++ b/homeassistant/components/flume/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/flume/translations/fr.json b/homeassistant/components/flume/translations/fr.json index 5fe7fcf2ca4..43157234eff 100644 --- a/homeassistant/components/flume/translations/fr.json +++ b/homeassistant/components/flume/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 98d11b0dc45..1a7aba5966b 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,7 +1,14 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from collections.abc import Mapping +from typing import Any, Union, cast + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -9,8 +16,9 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -57,42 +65,49 @@ USER_SENSOR_DESCRIPTIONS = ( name="Avian Flu Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_DENGUE, name="Dengue Fever Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_FLU, name="Flu Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_LEPTO, name="Leptospirosis Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_NO_SYMPTOMS, name="No Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_SYMPTOMS, name="Flu-like Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_USER_TOTAL, name="Total Symptoms", icon="mdi:alert", native_unit_of_measurement="reports", + state_class=STATE_CLASS_MEASUREMENT, ), ) @@ -125,6 +140,8 @@ async def async_setup_entry( class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Define a base Flu Near You sensor.""" + DEFAULT_EXTRA_STATE_ATTRIBUTES = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + def __init__( self, coordinator: DataUpdateCoordinator, @@ -134,7 +151,6 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._attr_unique_id = ( f"{entry.data[CONF_LATITUDE]}," f"{entry.data[CONF_LONGITUDE]}_{description.key}" @@ -142,68 +158,61 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): self._entry = entry self.entity_description = description - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.update_from_latest_data() - self.async_write_ha_state() - - 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) -> None: - """Update the sensor.""" - raise NotImplementedError - class CdcSensor(FluNearYouSensor): """Define a sensor for CDC reports.""" - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" - self._attr_extra_state_attributes.update( - { - ATTR_REPORTED_DATE: self.coordinator.data["week_date"], - ATTR_STATE: self.coordinator.data["name"], - } + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + return { + **self.DEFAULT_EXTRA_STATE_ATTRIBUTES, + ATTR_REPORTED_DATE: self.coordinator.data["week_date"], + ATTR_STATE: self.coordinator.data["name"], + } + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return cast( + Union[str, None], self.coordinator.data[self.entity_description.key] ) - self._attr_native_value = self.coordinator.data[self.entity_description.key] class UserSensor(FluNearYouSensor): """Define a sensor for user reports.""" - @callback - def update_from_latest_data(self) -> None: - """Update the sensor.""" - self._attr_extra_state_attributes.update( - { - ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], - ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], - ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], - ATTR_STATE: self.coordinator.data["state"]["name"], - ATTR_ZIP_CODE: self.coordinator.data["local"]["zip"], - } - ) + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attrs = { + **self.DEFAULT_EXTRA_STATE_ATTRIBUTES, + ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], + ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], + ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], + ATTR_STATE: self.coordinator.data["state"]["name"], + ATTR_ZIP_CODE: self.coordinator.data["local"]["zip"], + } if self.entity_description.key in self.coordinator.data["state"]["data"]: states_key = self.entity_description.key elif self.entity_description.key in EXTENDED_SENSOR_TYPE_MAPPING: states_key = EXTENDED_SENSOR_TYPE_MAPPING[self.entity_description.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] + attrs[ATTR_STATE_REPORTS_THIS_WEEK] = self.coordinator.data["state"]["data"][ + states_key + ] + attrs[ATTR_STATE_REPORTS_LAST_WEEK] = self.coordinator.data["state"][ + "last_week_data" + ][states_key] + return attrs + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" if self.entity_description.key == SENSOR_TYPE_USER_TOTAL: - self._attr_native_value = sum( + value = sum( v for k, v in self.coordinator.data["local"].items() if k @@ -216,6 +225,6 @@ class UserSensor(FluNearYouSensor): ) ) else: - self._attr_native_value = self.coordinator.data["local"][ - self.entity_description.key - ] + value = self.coordinator.data["local"][self.entity_description.key] + + return cast(int, value) diff --git a/homeassistant/components/flunearyou/translations/fr.json b/homeassistant/components/flunearyou/translations/fr.json index bd1cc30ca5b..a9d8064d865 100644 --- a/homeassistant/components/flunearyou/translations/fr.json +++ b/homeassistant/components/flunearyou/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "unknown": "Erreur inattendue" diff --git a/homeassistant/components/flux_led/translations/ca.json b/homeassistant/components/flux_led/translations/ca.json new file mode 100644 index 00000000000..25314edc1b8 --- /dev/null +++ b/homeassistant/components/flux_led/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'han trobat dispositius a la xarxa" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Vols configurar {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Si deixes l'amfitri\u00f3 buit, s'utilitzar\u00e0 el descobriment per cercar dispositius." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efecte personalitzat: llista d'1 a 16 colors [R,G,B]. Exemple: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efecte personalitzat: velocitat en percentatges de l'efecte de canvi de color.", + "custom_effect_transition": "Efecte personalitzat: tipus de transici\u00f3 entre colors.", + "mode": "Mode de brillantor escollit." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/de.json b/homeassistant/components/flux_led/translations/de.json new file mode 100644 index 00000000000..f036e7bd913 --- /dev/null +++ b/homeassistant/components/flux_led/translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "M\u00f6chtest du {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Wenn du den Host leer l\u00e4sst, wird die Erkennung verwendet, um Ger\u00e4te zu finden." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Benutzerdefinierter Effekt: Liste mit 1 bis 16 [R,G,B]-Farben. Beispiel: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Benutzerdefinierter Effekt: Geschwindigkeit in Prozent f\u00fcr den Effekt, der die Farbe wechselt.", + "custom_effect_transition": "Benutzerdefinierter Effekt: Art des \u00dcbergangs zwischen den Farben.", + "mode": "Der gew\u00e4hlte Helligkeitsmodus." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/en.json b/homeassistant/components/flux_led/translations/en.json new file mode 100644 index 00000000000..9a988408c30 --- /dev/null +++ b/homeassistant/components/flux_led/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Do you want to setup {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "If you leave the host empty, discovery will be used to find devices." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_transition": "Custom Effect: Type of transition between the colors.", + "mode": "The chosen brightness mode." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/et.json b/homeassistant/components/flux_led/translations/et.json new file mode 100644 index 00000000000..0c2e1f444cb --- /dev/null +++ b/homeassistant/components/flux_led/translations/et.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Kas seadistada {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Kui j\u00e4tad hosti t\u00fchjaks kasutatakse seadmete leidmiseks avastamist." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Kohandatud efekt: Loetelu 1 kuni 16 [R,G,B] v\u00e4rvist. N\u00e4ide: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Kohandatud efekt: v\u00e4rvide vahetamise efekti kiirus protsentides.", + "custom_effect_transition": "Kohandatud efekt: v\u00e4rvide vahelise \u00fclemineku t\u00fc\u00fcp.", + "mode": "Valitud heleduse re\u017eiim." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/hu.json b/homeassistant/components/flux_led/translations/hu.json new file mode 100644 index 00000000000..3cfef2c9eb6 --- /dev/null +++ b/homeassistant/components/flux_led/translations/hu.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {model} {id} ({ipaddr}) webhelyet?" + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Egy\u00e9ni effektus: 1-16 [R,G,B] sz\u00edn list\u00e1ja. P\u00e9lda: [255,0,255], [60,128,0]", + "custom_effect_speed_pct": "Egy\u00e9ni effektus: A sz\u00edneket v\u00e1lt\u00f3 hat\u00e1s sz\u00e1zal\u00e9kos ar\u00e1nya.", + "custom_effect_transition": "Egy\u00e9ni hat\u00e1s: A sz\u00ednek k\u00f6z\u00f6tti \u00e1tmenet t\u00edpusa.", + "mode": "A v\u00e1lasztott f\u00e9nyer\u0151 m\u00f3d." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/it.json b/homeassistant/components/flux_led/translations/it.json new file mode 100644 index 00000000000..91654fb3542 --- /dev/null +++ b/homeassistant/components/flux_led/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo trovato sulla rete" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Vuoi configurare {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Se lasci vuoto l'host, il rilevamento verr\u00e0 utilizzato per trovare i dispositivi." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Effetto personalizzato: Lista da 1 a 16 colori [R,G,B]. Esempio: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Effetto personalizzato: Velocit\u00e0 in percentuale per l'effetto che cambia colore.", + "custom_effect_transition": "Effetto personalizzato: Tipo di transizione tra i colori.", + "mode": "La modalit\u00e0 di luminosit\u00e0 scelta." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/nl.json b/homeassistant/components/flux_led/translations/nl.json new file mode 100644 index 00000000000..fd9e04bd475 --- /dev/null +++ b/homeassistant/components/flux_led/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Wilt u {model} {id} ( {ipaddr} ) instellen?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Als u de host leeg laat, wordt detectie gebruikt om apparaten te vinden." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Aangepast effect: Lijst van 1 tot 16 [R,G,B] kleuren. Voorbeeld: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Aangepast effect: snelheid in procenten voor het effect dat van kleur verandert.", + "custom_effect_transition": "Aangepast effect: Type overgang tussen de kleuren.", + "mode": "De gekozen helderheidsstand." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/no.json b/homeassistant/components/flux_led/translations/no.json new file mode 100644 index 00000000000..ec105c1ac14 --- /dev/null +++ b/homeassistant/components/flux_led/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{model} {id} ( {ipaddr} )", + "step": { + "discovery_confirm": { + "description": "Vil du konfigurere {model} {id} ( {ipaddr} )?" + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Hvis du lar verten st\u00e5 tom, brukes automatisk oppdagelse til \u00e5 finne enheter" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Egendefinert effekt: Liste med farger fra 1 til 16 [R,G,B]. Eksempel: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Egendefinert effekt: Hastighet i prosent for effekten som bytter farger.", + "custom_effect_transition": "Egendefinert effekt: Overgangstype mellom fargene.", + "mode": "Den valgte lysstyrkemodusen." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/ru.json b/homeassistant/components/flux_led/translations/ru.json new file mode 100644 index 00000000000..e0f7a73baab --- /dev/null +++ b/homeassistant/components/flux_led/translations/ru.json @@ -0,0 +1,36 @@ +{ + "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_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0415\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0441\u043f\u0438\u0441\u043e\u043a \u043e\u0442 1 \u0434\u043e 16 [R,G,B] \u0446\u0432\u0435\u0442\u043e\u0432. \u041d\u0430\u043f\u0440\u0438\u043c\u0435\u0440: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442\u043e\u0432 (\u0432 \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u0430\u0445).", + "custom_effect_transition": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u044d\u0444\u0444\u0435\u043a\u0442: \u0442\u0438\u043f \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430 \u043c\u0435\u0436\u0434\u0443 \u0446\u0432\u0435\u0442\u0430\u043c\u0438.", + "mode": "\u0412\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u044f\u0440\u043a\u043e\u0441\u0442\u0438." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/zh-Hant.json b/homeassistant/components/flux_led/translations/zh-Hant.json new file mode 100644 index 00000000000..4e14b58ff18 --- /dev/null +++ b/homeassistant/components/flux_led/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {model} {id} ({ipaddr})\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u5047\u5982\u4e3b\u6a5f\u7aef\u4f4d\u5740\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u63a2\u7d22\u6240\u6709\u53ef\u7528\u88dd\u7f6e\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u81ea\u8a02\u7279\u6548\uff1a1 \u5230 16 \u7a2e [R,G,B] \u984f\u8272\u3002\u4f8b\u5982\uff1a[255,0,255]\u3001[60,128,0]", + "custom_effect_speed_pct": "\u81ea\u8a02\u7279\u6548\uff1a\u984f\u8272\u5207\u63db\u7684\u901f\u5ea6\u767e\u5206\u6bd4\u3002", + "custom_effect_transition": "\u81ea\u8a02\u7279\u6548\uff1a\u984f\u8272\u9593\u7684\u8f49\u63db\u985e\u578b\u3002", + "mode": "\u9078\u64c7\u4eae\u5ea6\u6a21\u5f0f\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 9a7967d22cb..a8b084eb801 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==2.1.4"], + "requirements": ["watchdog==2.1.5"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py index 6bcc97d49f2..af9b6125713 100644 --- a/homeassistant/components/forecast_solar/models.py +++ b/homeassistant/components/forecast_solar/models.py @@ -1,8 +1,9 @@ """Models for the Forecast.Solar integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from forecast_solar.models import Estimate diff --git a/homeassistant/components/forecast_solar/translations/es.json b/homeassistant/components/forecast_solar/translations/es.json index 8a1b51a5084..d688c577024 100644 --- a/homeassistant/components/forecast_solar/translations/es.json +++ b/homeassistant/components/forecast_solar/translations/es.json @@ -5,7 +5,10 @@ "data": { "azimuth": "Acimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", - "modules power": "Potencia total en vatios pico de sus m\u00f3dulos solares" + "latitude": "Latitud", + "longitude": "Longitud", + "modules power": "Potencia total en vatios pico de sus m\u00f3dulos solares", + "name": "Nombre" }, "description": "Rellene los datos de sus paneles solares. Consulte la documentaci\u00f3n si alg\u00fan campo no est\u00e1 claro." } diff --git a/homeassistant/components/forecast_solar/translations/id.json b/homeassistant/components/forecast_solar/translations/id.json index b0a5ddcdc7e..130f66db7f5 100644 --- a/homeassistant/components/forecast_solar/translations/id.json +++ b/homeassistant/components/forecast_solar/translations/id.json @@ -3,7 +3,9 @@ "step": { "user": { "data": { - "latitude": "Lintang" + "latitude": "Lintang", + "longitude": "Bujur", + "name": "Nama" } } } diff --git a/homeassistant/components/forked_daapd/translations/fr.json b/homeassistant/components/forked_daapd/translations/fr.json index 2e20c75d33f..2b3633f01a0 100644 --- a/homeassistant/components/forked_daapd/translations/fr.json +++ b/homeassistant/components/forked_daapd/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "not_forked_daapd": "Le p\u00e9riph\u00e9rique n'est pas un serveur forked-daapd." }, "error": { "forbidden": "Impossible de se connecter. Veuillez v\u00e9rifier vos autorisations r\u00e9seau forked-daapd.", - "unknown_error": "Erreur inconnue", + "unknown_error": "Erreur inattendue", "websocket_not_enabled": "le socket web du serveur forked-daapd n'est pas activ\u00e9.", "wrong_host_or_port": "Impossible de se connecter. Veuillez v\u00e9rifier l'h\u00f4te et le port.", "wrong_password": "Mot de passe incorrect.", diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index bbf8cb560ff..2058bbd1cbe 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -8,15 +8,15 @@ "forbidden": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a forked-daapd h\u00e1l\u00f3zati enged\u00e9lyeket.", "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", "websocket_not_enabled": "forked-daapd szerver websocket nincs enged\u00e9lyezve.", - "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot.", + "wrong_host_or_port": "Nem tud csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze a c\u00edmet \u00e9s a portot.", "wrong_password": "Helytelen jelsz\u00f3.", - "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja> = 27.0." + "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja legal\u00e1bb 27.0." }, "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Megjelen\u00edt\u00e9si n\u00e9v", "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", "port": "API port" diff --git a/homeassistant/components/forked_daapd/translations/id.json b/homeassistant/components/forked_daapd/translations/id.json index 76787e2a19b..f57a8fb8566 100644 --- a/homeassistant/components/forked_daapd/translations/id.json +++ b/homeassistant/components/forked_daapd/translations/id.json @@ -12,7 +12,7 @@ "wrong_password": "Kata sandi salah.", "wrong_server_type": "Integrasi forked-daapd membutuhkan server forked-daapd dengan versi >= 27.0." }, - "flow_title": "forked-daapd server: {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/foscam/translations/fr.json b/homeassistant/components/foscam/translations/fr.json index 1424c22ad61..7c0bb8398da 100644 --- a/homeassistant/components/foscam/translations/fr.json +++ b/homeassistant/components/foscam/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connection", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "invalid_response": "R\u00e9ponse invalide de l\u2019appareil", "unknown": "Erreur inattendue" diff --git a/homeassistant/components/foscam/translations/hu.json b/homeassistant/components/foscam/translations/hu.json index 63ea95210ff..b303db792bb 100644 --- a/homeassistant/components/foscam/translations/hu.json +++ b/homeassistant/components/foscam/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "rtsp_port": "RTSP port", diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 6f33c9ff591..6dc0d1c8228 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -1,16 +1,12 @@ """Support for the Foursquare (Swarm) API.""" +from http import HTTPStatus import logging import requests import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - HTTP_BAD_REQUEST, - HTTP_CREATED, - HTTP_OK, -) +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_CREATED, HTTP_OK import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -95,7 +91,7 @@ class FoursquarePushReceiver(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) secret = data.pop("secret", None) @@ -105,6 +101,6 @@ class FoursquarePushReceiver(HomeAssistantView): _LOGGER.error( "Received Foursquare push with invalid push secret: %s", secret ) - return self.json_message("Incorrect secret", HTTP_BAD_REQUEST) + return self.json_message("Incorrect secret", HTTPStatus.BAD_REQUEST) request.app["hass"].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index df251dcf954..7183bd029ff 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -1,12 +1,10 @@ """Freebox component constants.""" +from __future__ import annotations + import socket -from homeassistant.const import ( - DATA_RATE_KILOBYTES_PER_SECOND, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_CELSIUS, -) +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND, PERCENTAGE DOMAIN = "freebox" SERVICE_REBOOT = "reboot" @@ -27,51 +25,39 @@ DEFAULT_DEVICE_NAME = "Unknown device" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -# Sensor -SENSOR_NAME = "name" -SENSOR_UNIT = "unit" -SENSOR_ICON = "icon" -SENSOR_DEVICE_CLASS = "device_class" -CONNECTION_SENSORS = { - "rate_down": { - SENSOR_NAME: "Freebox download speed", - SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - SENSOR_ICON: "mdi:download-network", - SENSOR_DEVICE_CLASS: None, - }, - "rate_up": { - SENSOR_NAME: "Freebox upload speed", - SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - SENSOR_ICON: "mdi:upload-network", - SENSOR_DEVICE_CLASS: None, - }, -} +CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="rate_down", + name="Freebox download speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download-network", + ), + SensorEntityDescription( + key="rate_up", + name="Freebox upload speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload-network", + ), +) +CONNECTION_SENSORS_KEYS: list[str] = [desc.key for desc in CONNECTION_SENSORS] -CALL_SENSORS = { - "missed": { - SENSOR_NAME: "Freebox missed calls", - SENSOR_UNIT: None, - SENSOR_ICON: "mdi:phone-missed", - SENSOR_DEVICE_CLASS: None, - }, -} +CALL_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="missed", + name="Freebox missed calls", + icon="mdi:phone-missed", + ), +) -DISK_PARTITION_SENSORS = { - "partition_free_space": { - SENSOR_NAME: "free space", - SENSOR_UNIT: PERCENTAGE, - SENSOR_ICON: "mdi:harddisk", - SENSOR_DEVICE_CLASS: None, - }, -} - -TEMPERATURE_SENSOR_TEMPLATE = { - SENSOR_NAME: None, - SENSOR_UNIT: TEMP_CELSIUS, - SENSOR_ICON: "mdi:thermometer", - SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, -} +DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="partition_free_space", + name="free space", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + ), +) # Icons DEVICE_ICONS = { diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 9438b3eadc6..0673d550d76 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -24,7 +24,7 @@ from homeassistant.util import slugify from .const import ( API_VERSION, APP_DESC, - CONNECTION_SENSORS, + CONNECTION_SENSORS_KEYS, DOMAIN, STORAGE_KEY, STORAGE_VERSION, @@ -141,7 +141,7 @@ class FreeboxRouter: # Connection sensors connection_datas: dict[str, Any] = await self._api.connection.get_status() - for sensor_key in CONNECTION_SENSORS: + for sensor_key in CONNECTION_SENSORS_KEYS: self.sensors_connection[sensor_key] = connection_datas[sensor_key] self._attrs = { diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 939c53b47db..654a73b786c 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -4,25 +4,19 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND +from homeassistant.const import ( + DATA_RATE_KILOBYTES_PER_SECOND, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo import homeassistant.util.dt as dt_util -from .const import ( - CALL_SENSORS, - CONNECTION_SENSORS, - DISK_PARTITION_SENSORS, - DOMAIN, - SENSOR_DEVICE_CLASS, - SENSOR_ICON, - SENSOR_NAME, - SENSOR_UNIT, - TEMPERATURE_SENSOR_TEMPLATE, -) +from .const import CALL_SENSORS, CONNECTION_SENSORS, DISK_PARTITION_SENSORS, DOMAIN from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -41,34 +35,33 @@ async def async_setup_entry( router.mac, len(router.sensors_temperature), ) - for sensor_name in router.sensors_temperature: - entities.append( - FreeboxSensor( - router, - sensor_name, - {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"}, - ) + entities = [ + FreeboxSensor( + router, + SensorEntityDescription( + key=sensor_name, + name=f"Freebox {sensor_name}", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), ) + for sensor_name in router.sensors_temperature + ] - for sensor_key, sensor in CONNECTION_SENSORS.items(): - entities.append(FreeboxSensor(router, sensor_key, sensor)) - - for sensor_key, sensor in CALL_SENSORS.items(): - entities.append(FreeboxCallSensor(router, sensor_key, sensor)) + entities.extend( + [FreeboxSensor(router, description) for description in CONNECTION_SENSORS] + ) + entities.extend( + [FreeboxCallSensor(router, description) for description in CALL_SENSORS] + ) _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, sensor in DISK_PARTITION_SENSORS.items(): - entities.append( - FreeboxDiskSensor( - router, - disk, - partition, - sensor_key, - sensor, - ) - ) + entities.extend( + FreeboxDiskSensor(router, disk, partition, description) + for disk in router.disks.values() + for partition in disk["partitions"] + for description in DISK_PARTITION_SENSORS + ) async_add_entities(entities, True) @@ -76,68 +69,30 @@ async def async_setup_entry( class FreeboxSensor(SensorEntity): """Representation of a Freebox sensor.""" + _attr_should_poll = False + def __init__( - self, router: FreeboxRouter, sensor_type: str, sensor: dict[str, Any] + self, router: FreeboxRouter, description: SensorEntityDescription ) -> None: """Initialize a Freebox sensor.""" - self._state = None + self.entity_description = description self._router = router - self._sensor_type = sensor_type - self._name = sensor[SENSOR_NAME] - self._unit = sensor[SENSOR_UNIT] - self._icon = sensor[SENSOR_ICON] - self._device_class = sensor[SENSOR_DEVICE_CLASS] - self._unique_id = f"{self._router.mac} {self._name}" + self._attr_unique_id = f"{router.mac} {description.name}" @callback def async_update_state(self) -> None: """Update the Freebox sensor.""" - state = self._router.sensors[self._sensor_type] - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: - self._state = round(state / 1000, 2) + state = self._router.sensors[self.entity_description.key] + if self.native_unit_of_measurement == DATA_RATE_KILOBYTES_PER_SECOND: + self._attr_native_value = round(state / 1000, 2) else: - self._state = 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 native_value(self) -> str: - """Return the state.""" - return self._state - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit.""" - return self._unit - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def device_class(self) -> str: - """Return the device_class.""" - return self._device_class + self._attr_native_value = state @property def device_info(self) -> DeviceInfo: """Return the device information.""" return self._router.device_info - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - @callback def async_on_demand_update(self): """Update state.""" @@ -160,10 +115,10 @@ class FreeboxCallSensor(FreeboxSensor): """Representation of a Freebox call sensor.""" def __init__( - self, router: FreeboxRouter, sensor_type: str, sensor: dict[str, Any] + self, router: FreeboxRouter, description: SensorEntityDescription ) -> None: """Initialize a Freebox call sensor.""" - super().__init__(router, sensor_type, sensor) + super().__init__(router, description) self._call_list_for_type = [] @callback @@ -174,10 +129,10 @@ class FreeboxCallSensor(FreeboxSensor): for call in self._router.call_list: if not call["new"]: continue - if call["type"] == self._sensor_type: + if self.entity_description.key == call["type"]: self._call_list_for_type.append(call) - self._state = len(self._call_list_for_type) + self._attr_native_value = len(self._call_list_for_type) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -196,15 +151,14 @@ class FreeboxDiskSensor(FreeboxSensor): router: FreeboxRouter, disk: dict[str, Any], partition: dict[str, Any], - sensor_type: str, - sensor: dict[str, Any], + description: SensorEntityDescription, ) -> None: """Initialize a Freebox disk sensor.""" - super().__init__(router, sensor_type, sensor) + super().__init__(router, description) self._disk = disk self._partition = partition - self._name = f"{partition['label']} {sensor[SENSOR_NAME]}" - self._unique_id = f"{self._router.mac} {sensor_type} {self._disk['id']} {self._partition['id']}" + self._attr_name = f"{partition['label']} {description.name}" + self._unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}" @property def device_info(self) -> DeviceInfo: @@ -223,6 +177,6 @@ class FreeboxDiskSensor(FreeboxSensor): @callback def async_update_state(self) -> None: """Update the Freebox disk sensor.""" - self._state = round( + self._attr_native_value = round( self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 ) diff --git a/homeassistant/components/freebox/translations/fr.json b/homeassistant/components/freebox/translations/fr.json index f06cfed6cd7..7b459ebc0ab 100644 --- a/homeassistant/components/freebox/translations/fr.json +++ b/homeassistant/components/freebox/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "register_failed": "\u00c9chec de l'inscription, veuillez r\u00e9essayer", - "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + "unknown": "Erreur inattendue" }, "step": { "link": { @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freebox/translations/hu.json b/homeassistant/components/freebox/translations/hu.json index c929d56f38e..873e1057c15 100644 --- a/homeassistant/components/freebox/translations/hu.json +++ b/homeassistant/components/freebox/translations/hu.json @@ -10,12 +10,12 @@ }, "step": { "link": { - "description": "Kattintson a \u201eK\u00fcld\u00e9s\u201d gombra, majd \u00e9rintse meg a jobbra mutat\u00f3 nyilat az \u00fatv\u00e1laszt\u00f3n a Freebox regisztr\u00e1l\u00e1s\u00e1hoz a HomeAssistant seg\u00edts\u00e9g\u00e9vel. \n\n ! [A gomb helye az \u00fatv\u00e1laszt\u00f3n] (/static/images/config_freebox.png)", + "description": "Kattintson a \u201eK\u00fcld\u00e9s\u201d gombra, majd \u00e9rintse meg a jobbra mutat\u00f3 nyilat az \u00fatv\u00e1laszt\u00f3n a Freebox regisztr\u00e1l\u00e1s\u00e1hoz Home Assistant seg\u00edts\u00e9g\u00e9vel. \n\n![A gomb helye a routeren] (/static/images/config_freebox.png)", "title": "Freebox \u00fatv\u00e1laszt\u00f3 linkel\u00e9se" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freedompro/translations/es.json b/homeassistant/components/freedompro/translations/es.json index b6f8afeaf6d..c08c30d64dc 100644 --- a/homeassistant/components/freedompro/translations/es.json +++ b/homeassistant/components/freedompro/translations/es.json @@ -12,7 +12,7 @@ "data": { "api_key": "Clave API" }, - "description": "Ingrese la clave API obtenida de https://home.freedompro.eu", + "description": "Ingresa la clave API obtenida de https://home.freedompro.eu", "title": "Clave API de Freedompro" } } diff --git a/homeassistant/components/freedompro/translations/fr.json b/homeassistant/components/freedompro/translations/fr.json index 6667226a206..090c95fa3c2 100644 --- a/homeassistant/components/freedompro/translations/fr.json +++ b/homeassistant/components/freedompro/translations/fr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez saisir la cl\u00e9 API obtenue sur https://home.freedompro.eu", "title": "Cl\u00e9 API Freedompro" diff --git a/homeassistant/components/freedompro/translations/id.json b/homeassistant/components/freedompro/translations/id.json index 82523dc65d1..9676af6d8f9 100644 --- a/homeassistant/components/freedompro/translations/id.json +++ b/homeassistant/components/freedompro/translations/id.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 7655df6e298..edde8c0c22a 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -1,11 +1,14 @@ """AVM FRITZ!Box connectivity sensor.""" -import logging +from __future__ import annotations -from fritzconnection.core.exceptions import FritzConnectionException +import logging from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_UPDATE, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -17,6 +20,25 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_connected", + name="Connection", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="is_linked", + name="Link", + device_class=DEVICE_CLASS_PLUG, + ), + BinarySensorEntityDescription( + key="firmware_update", + name="Firmware Update", + device_class=DEVICE_CLASS_UPDATE, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -24,72 +46,47 @@ async def async_setup_entry( _LOGGER.debug("Setting up FRITZ!Box binary sensors") fritzbox_tools: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] - if fritzbox_tools.connection and "WANIPConn1" in fritzbox_tools.connection.services: + if ( + not fritzbox_tools.connection + or "WANIPConn1" not in fritzbox_tools.connection.services + ): # Only routers are supported at the moment - async_add_entities( - [FritzBoxConnectivitySensor(fritzbox_tools, entry.title)], True - ) + return + + entities = [ + FritzBoxBinarySensor(fritzbox_tools, entry.title, description) + for description in SENSOR_TYPES + ] + + async_add_entities(entities, True) -class FritzBoxConnectivitySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" def __init__( - self, fritzbox_tools: FritzBoxTools, device_friendly_name: str + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + description: BinarySensorEntityDescription, ) -> None: """Init FRITZ!Box connectivity class.""" - self._unique_id = f"{fritzbox_tools.unique_id}-connectivity" - self._name = f"{device_friendly_name} Connectivity" - self._is_on = True - self._is_available = True + self.entity_description = description + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{description.key}" super().__init__(fritzbox_tools, device_friendly_name) - @property - def name(self) -> str: - """Return name.""" - return self._name - - @property - def device_class(self) -> str: - """Return device class.""" - return DEVICE_CLASS_CONNECTIVITY - - @property - def is_on(self) -> bool: - """Return status.""" - return self._is_on - - @property - def unique_id(self) -> str: - """Return unique id.""" - return self._unique_id - - @property - def available(self) -> bool: - """Return availability.""" - return self._is_available - def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box binary sensors") - self._is_on = True - try: - if ( - self._fritzbox_tools.connection - and "WANCommonInterfaceConfig1" - in self._fritzbox_tools.connection.services - ): - link_props = self._fritzbox_tools.connection.call_action( - "WANCommonInterfaceConfig1", "GetCommonLinkProperties" - ) - is_up = link_props["NewPhysicalLinkStatus"] - self._is_on = is_up == "Up" - else: - if self._fritzbox_tools.fritz_status: - self._is_on = self._fritzbox_tools.fritz_status.is_connected - self._is_available = True - - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._is_available = False + if self.entity_description.key == "is_connected": + self._attr_is_on = bool(self._fritzbox_tools.fritz_status.is_connected) + elif self.entity_description.key == "is_linked": + self._attr_is_on = bool(self._fritzbox_tools.fritz_status.is_linked) + elif self.entity_description.key == "firmware_update": + self._attr_is_on = self._fritzbox_tools.update_available + self._attr_extra_state_attributes = { + "installed_version": self._fritzbox_tools.current_firmware, + "latest_available_version:": self._fritzbox_tools.latest_firmware, + } diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 2a9a5e5cd2e..61cff890a93 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1,11 +1,12 @@ """Support for AVM FRITZ!Box classes.""" from __future__ import annotations +from collections.abc import Callable, ValuesView from dataclasses import dataclass, field from datetime import datetime, timedelta import logging from types import MappingProxyType -from typing import Any, Callable, TypedDict +from typing import Any, TypedDict from fritzconnection import FritzConnection from fritzconnection.core.exceptions import ( @@ -42,6 +43,33 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +def _is_tracked(mac: str, current_devices: ValuesView) -> bool: + """Check if device is already tracked.""" + for tracked in current_devices: + if mac in tracked: + return True + return False + + +def device_filter_out_from_trackers( + mac: str, + device: FritzDevice, + current_devices: ValuesView, +) -> bool: + """Check if device should be filtered out from trackers.""" + reason: str | None = None + if device.ip_address == "": + reason = "Missing IP" + elif _is_tracked(mac, current_devices): + reason = "Already tracked" + + if reason: + _LOGGER.debug( + "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason + ) + return bool(reason) + + class ClassSetupMissing(Exception): """Raised when a Class func is called before setup.""" @@ -94,7 +122,9 @@ class FritzBoxTools: self.username = username self._mac: str | None = None self._model: str | None = None - self._sw_version: str | None = None + self._current_firmware: str | None = None + self._latest_firmware: str | None = None + self._update_available: bool = False async def async_setup(self) -> None: """Wrap up FritzboxTools class setup.""" @@ -121,7 +151,9 @@ class FritzBoxTools: self._unique_id = info["NewSerialNumber"] self._model = info.get("NewModelName") - self._sw_version = info.get("NewSoftwareVersion") + self._current_firmware = info.get("NewSoftwareVersion") + + self._update_available, self._latest_firmware = self._update_device_info() async def async_start(self, options: MappingProxyType[str, Any]) -> None: """Start FritzHosts connection.""" @@ -156,11 +188,21 @@ class FritzBoxTools: return self._model @property - def sw_version(self) -> str: - """Return SW version.""" - if not self._sw_version: + def current_firmware(self) -> str: + """Return current SW version.""" + if not self._current_firmware: raise ClassSetupMissing() - return self._sw_version + return self._current_firmware + + @property + def latest_firmware(self) -> str | None: + """Return latest SW version.""" + return self._latest_firmware + + @property + def update_available(self) -> bool: + """Return if new SW version is available.""" + return self._update_available @property def mac(self) -> str: @@ -170,7 +212,7 @@ class FritzBoxTools: return self._unique_id @property - def devices(self) -> dict[str, Any]: + def devices(self) -> dict[str, FritzDevice]: """Return devices.""" return self._devices @@ -184,9 +226,21 @@ class FritzBoxTools: """Event specific per FRITZ!Box entry to signal updates in devices.""" return f"{DOMAIN}-device-update-{self._unique_id}" - def _update_info(self) -> list[HostInfo]: - """Retrieve latest information from the FRITZ!Box.""" - return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] + def _update_hosts_info(self) -> list[HostInfo]: + """Retrieve latest hosts information from the FRITZ!Box.""" + try: + return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] + except Exception as ex: # pylint: disable=[broad-except] + if not self.hass.is_stopping: + raise HomeAssistantError("Error refreshing hosts info") from ex + return [] + + def _update_device_info(self) -> tuple[bool, str | None]: + """Retrieve latest device information from the FRITZ!Box.""" + userinterface = self.connection.call_action("UserInterface1", "GetInfo") + return userinterface.get("NewUpgradeAvailable"), userinterface.get( + "NewX_AVM-DE_Version" + ) def scan_devices(self, now: datetime | None = None) -> None: """Scan for new devices and return a list of found device ids.""" @@ -201,7 +255,7 @@ class FritzBoxTools: consider_home = _default_consider_home new_device = False - for known_host in self._update_info(): + for known_host in self._update_hosts_info(): if not known_host.get("mac"): continue @@ -224,6 +278,9 @@ class FritzBoxTools: if new_device: dispatcher_send(self.hass, self.signal_device_new) + _LOGGER.debug("Checking host info for FRITZ!Box router %s", self.host) + self._update_available, self._latest_firmware = self._update_device_info() + async def service_fritzbox(self, service: str) -> None: """Define FRITZ!Box services.""" _LOGGER.debug("FRITZ!Box router: %s", service) @@ -429,5 +486,5 @@ class FritzBoxBaseEntity: "name": self._device_name, "manufacturer": "AVM", "model": self._fritzbox_tools.model, - "sw_version": self._fritzbox_tools.sw_version, + "sw_version": self._fritzbox_tools.current_firmware, } diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 4ae8314113f..3ed4e705730 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -1,12 +1,14 @@ """Constants for the FRITZ!Box Tools integration.""" +from typing import Literal + DOMAIN = "fritz" PLATFORMS = ["binary_sensor", "device_tracker", "sensor", "switch"] DATA_FRITZ = "fritz_data" -DSL_CONNECTION = "dsl" +DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.178.1" diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e18ec8005cc..9483d8163e0 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -20,7 +20,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .common import FritzBoxTools, FritzData, FritzDevice, FritzDeviceBase +from .common import ( + FritzBoxTools, + FritzData, + FritzDevice, + FritzDeviceBase, + device_filter_out_from_trackers, +) from .const import DATA_FRITZ, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -91,19 +97,12 @@ def _async_add_entities( ) -> None: """Add new tracker entities from the router.""" - def _is_tracked(mac: str) -> bool: - for tracked in data_fritz.tracked.values(): - if mac in tracked: - return True - - return False - new_tracked = [] if router.unique_id not in data_fritz.tracked: data_fritz.tracked[router.unique_id] = set() for mac, device in router.devices.items(): - if device.ip_address == "" or _is_tracked(mac): + if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()): continue new_tracked.append(FritzBoxTracker(router, device)) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 53efc7a83f3..fd82d245b9a 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -1,9 +1,10 @@ """AVM FRITZ!Box binary sensors.""" from __future__ import annotations +from dataclasses import dataclass import datetime import logging -from typing import Callable, TypedDict +from typing import Any, Callable, Literal from fritzconnection.core.exceptions import ( FritzActionError, @@ -18,6 +19,7 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -138,117 +140,134 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] -class SensorData(TypedDict, total=False): - """Sensor data class.""" +@dataclass +class FritzRequireKeysMixin: + """Fritz sensor data class.""" - name: str - device_class: str | None - state_class: str | None - unit_of_measurement: str | None - icon: str | None - state_provider: Callable - connection_type: str | None + value_fn: Callable[[FritzStatus, Any], Any] -SENSOR_DATA = { - "external_ip": SensorData( +@dataclass +class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): + """Describes Fritz sensor entity.""" + + connection_type: Literal["dsl"] | None = None + + +SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( + FritzSensorEntityDescription( + key="external_ip", name="External IP", icon="mdi:earth", - state_provider=_retrieve_external_ip_state, + value_fn=_retrieve_external_ip_state, ), - "device_uptime": SensorData( + FritzSensorEntityDescription( + key="device_uptime", name="Device Uptime", device_class=DEVICE_CLASS_TIMESTAMP, - state_provider=_retrieve_device_uptime_state, + value_fn=_retrieve_device_uptime_state, ), - "connection_uptime": SensorData( + FritzSensorEntityDescription( + key="connection_uptime", name="Connection Uptime", device_class=DEVICE_CLASS_TIMESTAMP, - state_provider=_retrieve_connection_uptime_state, + value_fn=_retrieve_connection_uptime_state, ), - "kb_s_sent": SensorData( + FritzSensorEntityDescription( + key="kb_s_sent", name="Upload Throughput", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:upload", - state_provider=_retrieve_kb_s_sent_state, + value_fn=_retrieve_kb_s_sent_state, ), - "kb_s_received": SensorData( + FritzSensorEntityDescription( + key="kb_s_received", name="Download Throughput", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:download", - state_provider=_retrieve_kb_s_received_state, + value_fn=_retrieve_kb_s_received_state, ), - "max_kb_s_sent": SensorData( + FritzSensorEntityDescription( + key="max_kb_s_sent", name="Max Connection Upload Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:upload", - state_provider=_retrieve_max_kb_s_sent_state, + value_fn=_retrieve_max_kb_s_sent_state, ), - "max_kb_s_received": SensorData( + FritzSensorEntityDescription( + key="max_kb_s_received", name="Max Connection Download Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", - state_provider=_retrieve_max_kb_s_received_state, + value_fn=_retrieve_max_kb_s_received_state, ), - "gb_sent": SensorData( + FritzSensorEntityDescription( + key="gb_sent", name="GB sent", state_class=STATE_CLASS_TOTAL_INCREASING, - unit_of_measurement=DATA_GIGABYTES, + native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:upload", - state_provider=_retrieve_gb_sent_state, + value_fn=_retrieve_gb_sent_state, ), - "gb_received": SensorData( + FritzSensorEntityDescription( + key="gb_received", name="GB received", state_class=STATE_CLASS_TOTAL_INCREASING, - unit_of_measurement=DATA_GIGABYTES, + native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:download", - state_provider=_retrieve_gb_received_state, + value_fn=_retrieve_gb_received_state, ), - "link_kb_s_sent": SensorData( + FritzSensorEntityDescription( + key="link_kb_s_sent", name="Link Upload Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:upload", - state_provider=_retrieve_link_kb_s_sent_state, + value_fn=_retrieve_link_kb_s_sent_state, connection_type=DSL_CONNECTION, ), - "link_kb_s_received": SensorData( + FritzSensorEntityDescription( + key="link_kb_s_received", name="Link Download Throughput", - unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", - state_provider=_retrieve_link_kb_s_received_state, + value_fn=_retrieve_link_kb_s_received_state, connection_type=DSL_CONNECTION, ), - "link_noise_margin_sent": SensorData( + FritzSensorEntityDescription( + key="link_noise_margin_sent", name="Link Upload Noise Margin", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", - state_provider=_retrieve_link_noise_margin_sent_state, + value_fn=_retrieve_link_noise_margin_sent_state, connection_type=DSL_CONNECTION, ), - "link_noise_margin_received": SensorData( + FritzSensorEntityDescription( + key="link_noise_margin_received", name="Link Download Noise Margin", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", - state_provider=_retrieve_link_noise_margin_received_state, + value_fn=_retrieve_link_noise_margin_received_state, connection_type=DSL_CONNECTION, ), - "link_attenuation_sent": SensorData( + FritzSensorEntityDescription( + key="link_attenuation_sent", name="Link Upload Power Attenuation", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:upload", - state_provider=_retrieve_link_attenuation_sent_state, + value_fn=_retrieve_link_attenuation_sent_state, connection_type=DSL_CONNECTION, ), - "link_attenuation_received": SensorData( + FritzSensorEntityDescription( + key="link_attenuation_received", name="Link Download Power Attenuation", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, icon="mdi:download", - state_provider=_retrieve_link_attenuation_received_state, + value_fn=_retrieve_link_attenuation_received_state, connection_type=DSL_CONNECTION, ), -} +) async def async_setup_entry( @@ -265,7 +284,6 @@ async def async_setup_entry( # Only routers are supported at the moment return - entities = [] dsl: bool = False try: dslinterface = await hass.async_add_executor_job( @@ -282,40 +300,34 @@ async def async_setup_entry( ): pass - for sensor_type, sensor_data in SENSOR_DATA.items(): - if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION: - continue - entities.append(FritzBoxSensor(fritzbox_tools, entry.title, sensor_type)) + entities = [ + FritzBoxSensor(fritzbox_tools, entry.title, description) + for description in SENSOR_TYPES + if dsl or description.connection_type != DSL_CONNECTION + ] - if entities: - async_add_entities(entities, True) + async_add_entities(entities, True) class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" + entity_description: FritzSensorEntityDescription + def __init__( - self, fritzbox_tools: FritzBoxTools, device_friendly_name: str, sensor_type: str + self, + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + description: FritzSensorEntityDescription, ) -> None: """Init FRITZ!Box connectivity class.""" - self._sensor_data: SensorData = SENSOR_DATA[sensor_type] + self.entity_description = description self._last_device_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_native_unit_of_measurement = self._sensor_data.get( - "unit_of_measurement" - ) - self._attr_unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" + self._attr_name = f"{device_friendly_name} {description.name}" + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{description.key}" super().__init__(fritzbox_tools, device_friendly_name) - @property - def _state_provider(self) -> Callable: - """Return the state provider for the binary sensor.""" - return self._sensor_data["state_provider"] - def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box sensors") @@ -328,6 +340,6 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): self._attr_available = False return - self._attr_native_value = self._last_device_value = self._state_provider( - status, self._last_device_value - ) + self._attr_native_value = ( + self._last_device_value + ) = self.entity_description.value_fn(status, self._last_device_value) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 430817d4506..a53d0867a3c 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -31,6 +31,7 @@ from .common import ( FritzDevice, FritzDeviceBase, SwitchInfo, + device_filter_out_from_trackers, ) from .const import ( DATA_FRITZ, @@ -166,7 +167,7 @@ def port_entities_list( """Get list of port forwarding entities.""" _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PORTFORWARD) - entities_list: list = [] + entities_list: list[FritzBoxPortSwitch] = [] service_name = "Layer3Forwarding" connection_type = service_call_action( fritzbox_tools, service_name, "1", "GetDefaultConnectionService" @@ -218,11 +219,18 @@ def port_entities_list( # We can only handle port forwards of the given device if portmap["NewInternalClient"] == local_ip: + port_name = portmap["NewPortMappingDescription"] + for entity in entities_list: + if entity.port_mapping and ( + port_name in entity.port_mapping["NewPortMappingDescription"] + ): + port_name = f"{port_name} {portmap['NewExternalPort']}" entities_list.append( FritzBoxPortSwitch( fritzbox_tools, device_friendly_name, portmap, + port_name, i, con_type, ) @@ -267,17 +275,11 @@ def wifi_entities_list( def profile_entities_list( - router: FritzBoxTools, data_fritz: FritzData + 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: @@ -287,7 +289,9 @@ def profile_entities_list( data_fritz.profile_switches[router.unique_id] = set() for mac, device in router.devices.items(): - if device.ip_address == "" or _is_tracked(mac): + if device_filter_out_from_trackers( + mac, device, data_fritz.profile_switches.values() + ): continue new_profiles.append(FritzBoxProfileSwitch(router, device)) @@ -326,7 +330,11 @@ async def async_setup_entry( ) entities_list = await hass.async_add_executor_job( - all_entities_list, fritzbox_tools, entry.title, data_fritz, local_ip + all_entities_list, + fritzbox_tools, + entry.title, + data_fritz, + local_ip, ) async_add_entities(entities_list) @@ -422,6 +430,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): fritzbox_tools: FritzBoxTools, device_friendly_name: str, port_mapping: dict[str, Any] | None, + port_name: str, idx: int, connection_type: str, ) -> None: @@ -437,7 +446,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): return switch_info = SwitchInfo( - description=f'Port forward {port_mapping["NewPortMappingDescription"]}', + description=f"Port forward {port_name}", friendly_name=device_friendly_name, icon="mdi:check-network", type=SWITCH_TYPE_PORTFORWARD, diff --git a/homeassistant/components/fritz/translations/es.json b/homeassistant/components/fritz/translations/es.json index ed39b227ec8..45519eb7eb5 100644 --- a/homeassistant/components/fritz/translations/es.json +++ b/homeassistant/components/fritz/translations/es.json @@ -8,6 +8,7 @@ "error": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", + "cannot_connect": "No se pudo conectar", "connection_error": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, @@ -18,7 +19,7 @@ "password": "Contrase\u00f1a", "username": "Usuario" }, - "description": "Descubierto FRITZ!Box: {nombre}\n\nConfigurar FRITZ!Box Tools para controlar tu {nombre}", + "description": "Descubierto FRITZ!Box: {name}\n\nConfigurar FRITZ!Box Tools para controlar tu {name}", "title": "Configurar FRITZ!Box Tools" }, "reauth_confirm": { @@ -40,6 +41,12 @@ "title": "Configurar FRITZ!Box Tools - obligatorio" }, "user": { + "data": { + "host": "Anfitri\u00f3n", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + }, "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\n M\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", "title": "Configurar las herramientas de FRITZ! Box" } diff --git a/homeassistant/components/fritz/translations/fr.json b/homeassistant/components/fritz/translations/fr.json index 6518b5ed20c..f46c47cab5e 100644 --- a/homeassistant/components/fritz/translations/fr.json +++ b/homeassistant/components/fritz/translations/fr.json @@ -2,14 +2,14 @@ "config": { "abort": { "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" + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9 ", - "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", - "connection_error": "Erreur de connexion", + "connection_error": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, "flow_title": "FRITZ!Box Tools : {name}", diff --git a/homeassistant/components/fritz/translations/hu.json b/homeassistant/components/fritz/translations/hu.json index 1433860bfa6..733a4fb1a8e 100644 --- a/homeassistant/components/fritz/translations/hu.json +++ b/homeassistant/components/fritz/translations/hu.json @@ -32,7 +32,7 @@ }, "start_config": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" @@ -42,7 +42,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/fritz/translations/ko.json b/homeassistant/components/fritz/translations/ko.json new file mode 100644 index 00000000000..718b105df33 --- /dev/null +++ b/homeassistant/components/fritz/translations/ko.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4", + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "reauth_confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + }, + "start_config": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/ru.json b/homeassistant/components/fritz/translations/ru.json index 7c030ca4d88..54619e22a36 100644 --- a/homeassistant/components/fritz/translations/ru.json +++ b/homeassistant/components/fritz/translations/ru.json @@ -56,7 +56,7 @@ "step": { "init": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u0414\u043e\u043c\u0430\"" + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" } } } diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index ce5e74cfeec..8d354f655f6 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -6,12 +6,8 @@ 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, - ATTR_ENTITY_ID, - ATTR_NAME, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, @@ -20,15 +16,23 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONF_CONNECTIONS, CONF_COORDINATOR, DOMAIN, LOGGER, PLATFORMS -from .model import EntityInfo +from .const import ( + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_LOCKED, + CONF_CONNECTIONS, + CONF_COORDINATOR, + DOMAIN, + LOGGER, + PLATFORMS, +) +from .model import FritzExtraAttributes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -64,6 +68,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {} for device in devices: device.update() + + # assume device as unavailable, see #55799 + if ( + device.has_powermeter + and device.present + and hasattr(device, "voltage") + and device.voltage <= 0 + and device.power <= 0 + and device.energy <= 0 + ): + device.present = False + data[device.ain] = device return data @@ -128,18 +144,21 @@ class FritzBoxEntity(CoordinatorEntity): def __init__( self, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], ain: str, + entity_description: EntityDescription | None = None, ) -> None: """Initialize the FritzBox entity.""" super().__init__(coordinator) self.ain = ain - self._name = entity_info[ATTR_NAME] - self._unique_id = entity_info[ATTR_ENTITY_ID] - self._device_class = entity_info[ATTR_DEVICE_CLASS] - self._attr_state_class = entity_info[ATTR_STATE_CLASS] + if entity_description is not None: + self.entity_description = entity_description + self._attr_name = f"{self.device.name} {entity_description.name}" + self._attr_unique_id = f"{ain}_{entity_description.key}" + else: + self._attr_name = self.device.name + self._attr_unique_id = ain @property def available(self) -> bool: @@ -163,16 +182,9 @@ class FritzBoxEntity(CoordinatorEntity): } @property - def unique_id(self) -> str: - """Return the unique ID of the device.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return the device class.""" - return self._device_class + def extra_state_attributes(self) -> FritzExtraAttributes: + """Return the state attributes of the device.""" + return { + ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, + ATTR_STATE_LOCKED: self.device.lock, + } diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 5514408cb3c..1317710c570 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,57 +1,85 @@ """Support for Fritzbox binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from pyfritzhome.fritzhomedevice import FritzhomeDevice + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_WINDOW, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .model import FritzEntityDescriptionMixinBase + + +@dataclass +class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): + """BinarySensor description mixin for Fritz!Smarthome entities.""" + + is_on: Callable[[FritzhomeDevice], bool | None] + + +@dataclass +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor +): + """Description for Fritz!Smarthome binary sensor entities.""" + + +BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( + FritzBinarySensorEntityDescription( + key="alarm", + name="Alarm", + device_class=DEVICE_CLASS_WINDOW, + suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] + is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + ), +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - entities: list[FritzboxBinarySensor] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_alarm: - continue - - entities.append( - FritzboxBinarySensor( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_WINDOW, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxBinarySensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in BINARY_SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): """Representation of a binary FRITZ!SmartHome device.""" + entity_description: FritzBinarySensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], + ain: str, + entity_description: FritzBinarySensorEntityDescription, + ) -> None: + """Initialize the FritzBox entity.""" + super().__init__(coordinator, ain, entity_description) + self._attr_name = self.device.name + self._attr_unique_id = ain + @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if sensor is on.""" - return self.device.alert_state # type: ignore [no-any-return] + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 4baa1b3b81a..bf2d857e30e 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -13,15 +13,10 @@ 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, - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, PRECISION_HALVES, TEMP_CELSIUS, ) @@ -61,28 +56,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - entities: list[FritzboxThermostat] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_thermostat: - continue - - entities.append( - FritzboxThermostat( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxThermostat(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_thermostat + ] + ) class FritzboxThermostat(FritzBoxEntity, ClimateEntity): @@ -132,9 +114,9 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): @property def hvac_mode(self) -> str: """Return the current operation mode.""" - if ( - self.device.target_temperature == OFF_REPORT_SET_TEMPERATURE - or self.device.target_temperature == OFF_API_TEMPERATURE + if self.device.target_temperature in ( + OFF_REPORT_SET_TEMPERATURE, + OFF_API_TEMPERATURE, ): return HVAC_MODE_OFF diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 6af75449a29..67e7c9dc564 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -11,8 +11,6 @@ ATTR_STATE_LOCKED: Final = "locked" ATTR_STATE_SUMMER_MODE: Final = "summer_mode" ATTR_STATE_WINDOW_OPEN: Final = "window_open" -ATTR_TEMPERATURE_UNIT: Final = "temperature_unit" - CONF_CONNECTIONS: Final = "connections" CONF_COORDINATOR: Final = "coordinator" diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 0e401a75be3..fa6da56caeb 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -1,44 +1,32 @@ """Models for the AVM FRITZ!SmartHome integration.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import TypedDict - -class EntityInfo(TypedDict): - """TypedDict for EntityInfo.""" - - name: str - entity_id: str - unit_of_measurement: str | None - device_class: str | None - state_class: str | None +from pyfritzhome import FritzhomeDevice -class ClimateExtraAttributes(TypedDict, total=False): +class FritzExtraAttributes(TypedDict): + """TypedDict for sensors extra attributes.""" + + device_locked: bool + locked: bool + + +class ClimateExtraAttributes(FritzExtraAttributes, total=False): """TypedDict for climates extra attributes.""" battery_low: bool - device_locked: bool - locked: bool battery_level: int holiday_mode: bool summer_mode: bool window_open: bool -class SensorExtraAttributes(TypedDict): - """TypedDict for sensors extra attributes.""" +@dataclass +class FritzEntityDescriptionMixinBase: + """Bases description mixin for Fritz!Smarthome entities.""" - device_locked: bool - locked: bool - - -class SwitchExtraAttributes(TypedDict, total=False): - """TypedDict for sensors extra attributes.""" - - device_locked: bool - locked: bool - total_consumption: str - total_consumption_unit: str - temperature: str - temperature_unit: str + suitable: Callable[[FritzhomeDevice], bool] diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 09a652d64ad..0745ddc8331 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,22 +1,23 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from pyfritzhome import FritzhomeDevice +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from pyfritzhome.fritzhomedevice import FritzhomeDevice from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, @@ -26,145 +27,98 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .model import FritzEntityDescriptionMixinBase + + +@dataclass +class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): + """Sensor description mixin for Fritz!Smarthome entities.""" + + native_value: Callable[[FritzhomeDevice], float | int | None] + + +@dataclass +class FritzSensorEntityDescription( + SensorEntityDescription, FritzEntityDescriptionMixinSensor +): + """Description for Fritz!Smarthome sensor entities.""" + + +SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( + FritzSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: ( + device.has_temperature_sensor and not device.has_thermostat + ), + native_value=lambda device: device.temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: device.rel_humidity is not None, + native_value=lambda device: device.rel_humidity, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + suitable=lambda device: device.battery_level is not None, + native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.power / 1000 if device.power else 0.0, + ), + FritzSensorEntityDescription( + key="total_energy", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.energy / 1000 if device.energy else 0.0, + ), ) -from .model import EntityInfo, SensorExtraAttributes async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - entities: list[FritzBoxEntity] = [] 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_thermostat: - entities.append( - FritzBoxTempSensor( - { - ATTR_NAME: f"{device.name} Temperature", - ATTR_ENTITY_ID: f"{device.ain}_temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - coordinator, - ain, - ) - ) - - if device.battery_level is not None: - entities.append( - FritzBoxBatterySensor( - { - ATTR_NAME: f"{device.name} Battery", - 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_TOTAL_INCREASING, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzBoxSensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzBoxSensor(FritzBoxEntity, SensorEntity): """The entity class for FRITZ!SmartHome sensors.""" - def __init__( - self, - entity_info: EntityInfo, - coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], - ain: str, - ) -> None: - """Initialize the FritzBox entity.""" - FritzBoxEntity.__init__(self, entity_info, coordinator, ain) - self._attr_native_unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] - - -class FritzBoxBatterySensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome battery sensors.""" + entity_description: FritzSensorEntityDescription @property - def native_value(self) -> int | None: + def native_value(self) -> float | int | None: """Return the state of the sensor.""" - return self.device.battery_level # type: ignore [no-any-return] - - -class FritzBoxPowerSensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome power consumption sensors.""" - - @property - def native_value(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(FritzBoxSensor): - """The entity class for FRITZ!SmartHome total energy sensors.""" - - @property - def native_value(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 - - -class FritzBoxTempSensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome temperature sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - return self.device.temperature # type: ignore [no-any-return] - - @property - def extra_state_attributes(self) -> SensorExtraAttributes: - """Return the state attributes of the device.""" - attrs: SensorExtraAttributes = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs + return self.entity_description.native_value(self.device) diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 133db92feda..79f256bded0 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -3,54 +3,28 @@ 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_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, -) -from .model import SwitchExtraAttributes +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - entities: list[FritzboxSwitch] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_switch: - continue - - entities.append( - FritzboxSwitch( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxSwitch(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_switch + ] + ) class FritzboxSwitch(FritzBoxEntity, SwitchEntity): @@ -70,12 +44,3 @@ class FritzboxSwitch(FritzBoxEntity, SwitchEntity): """Turn the switch off.""" await self.hass.async_add_executor_job(self.device.set_switch_state_off) await self.coordinator.async_refresh() - - @property - def extra_state_attributes(self) -> SwitchExtraAttributes: - """Return the state attributes of the device.""" - attrs: SwitchExtraAttributes = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs diff --git a/homeassistant/components/fritzbox/translations/fr.json b/homeassistant/components/fritzbox/translations/fr.json index e6302964988..1f9d5d9893b 100644 --- a/homeassistant/components/fritzbox/translations/fr.json +++ b/homeassistant/components/fritzbox/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cette AVM FRITZ!Box est d\u00e9j\u00e0 configur\u00e9e.", - "already_in_progress": "Une configuration d'AVM FRITZ!Box est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "not_supported": "Connect\u00e9 \u00e0 AVM FRITZ! Box mais impossible de contr\u00f4ler les appareils Smart Home.", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "username": "Nom d'utilisateur" }, diff --git a/homeassistant/components/fritzbox/translations/hu.json b/homeassistant/components/fritzbox/translations/hu.json index 50a81601310..c5d5e495131 100644 --- a/homeassistant/components/fritzbox/translations/hu.json +++ b/homeassistant/components/fritzbox/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "not_supported": "Csatlakoztatva az AVM FRITZ! Boxhoz, de nem tudja vez\u00e9relni az intelligens otthoni eszk\u00f6z\u00f6ket.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" @@ -17,7 +17,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" }, "reauth_confirm": { "data": { @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/fritzbox/translations/id.json b/homeassistant/components/fritzbox/translations/id.json index 8dbd1f71534..f9c4f09b4ae 100644 --- a/homeassistant/components/fritzbox/translations/id.json +++ b/homeassistant/components/fritzbox/translations/id.json @@ -10,7 +10,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/fr.json b/homeassistant/components/fritzbox_callmonitor/translations/fr.json index cde9023273c..99baf51d0bd 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/fr.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "insufficient_permissions": "L'utilisateur ne dispose pas des autorisations n\u00e9cessaires pour acc\u00e9der aux param\u00e8tres d'AVM FRITZ! Box et \u00e0 ses r\u00e9pertoires.", - "no_devices_found": "Aucun appreil trouv\u00e9 sur le r\u00e9seau " + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" }, "error": { "invalid_auth": "Authentification invalide" @@ -17,10 +17,10 @@ }, "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "username": "Nom d'utilisateur " + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/fritzbox_callmonitor/translations/hu.json b/homeassistant/components/fritzbox_callmonitor/translations/hu.json index 5006dd77f14..86b4c637ca0 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/hu.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/hu.json @@ -17,7 +17,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/fritzbox_callmonitor/translations/id.json b/homeassistant/components/fritzbox_callmonitor/translations/id.json index 43bb4a16b47..1325edd720c 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/id.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/id.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "Pantau panggilan AVM FRITZ!Box: {name}", + "flow_title": "{name}", "step": { "phonebook": { "data": { diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 076420656fd..121cf6ea754 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==20210830.0" + "home-assistant-frontend==20211006.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 294b707c965..0b04655bd86 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,8 +1,9 @@ """API for persistent storage for the frontend.""" from __future__ import annotations +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/garages_amsterdam/translations/es.json b/homeassistant/components/garages_amsterdam/translations/es.json index 3bf5c176b56..79433b6b854 100644 --- a/homeassistant/components/garages_amsterdam/translations/es.json +++ b/homeassistant/components/garages_amsterdam/translations/es.json @@ -1,5 +1,10 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/gdacs/translations/fr.json b/homeassistant/components/gdacs/translations/fr.json index df44a1d9fa5..b1e77bf4d43 100644 --- a/homeassistant/components/gdacs/translations/fr.json +++ b/homeassistant/components/gdacs/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index f296b861aa4..c48deba12d8 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -182,14 +182,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._temp_lock = asyncio.Lock() self._min_temp = min_temp self._max_temp = max_temp + self._attr_preset_mode = PRESET_NONE self._target_temp = target_temp self._unit = unit self._unique_id = unique_id self._support_flags = SUPPORT_FLAGS if away_temp: self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE + self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] + else: + self._attr_preset_modes = [PRESET_NONE] self._away_temp = away_temp - self._is_away = False async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -253,8 +256,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ) else: self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) - if old_state.attributes.get(ATTR_PRESET_MODE) == PRESET_AWAY: - self._is_away = True + if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes: + self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) if not self._hvac_mode and old_state.state: self._hvac_mode = old_state.state @@ -341,16 +344,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """List of available operation modes.""" return self._hvac_list - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - return PRESET_AWAY if self._is_away else PRESET_NONE - - @property - def preset_modes(self): - """Return a list of available preset modes or PRESET_NONE if _away_temp is undefined.""" - return [PRESET_NONE, PRESET_AWAY] if self._away_temp else PRESET_NONE - async def async_set_hvac_mode(self, hvac_mode): """Set hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: @@ -530,13 +523,20 @@ class GenericThermostat(ClimateEntity, RestoreEntity): async def async_set_preset_mode(self, preset_mode: str): """Set new preset mode.""" - if preset_mode == PRESET_AWAY and not self._is_away: - self._is_away = True + if preset_mode not in (self._attr_preset_modes or []): + raise ValueError( + f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" + ) + if preset_mode == self._attr_preset_mode: + # I don't think we need to call async_write_ha_state if we didn't change the state + return + if preset_mode == PRESET_AWAY: + self._attr_preset_mode = PRESET_AWAY self._saved_target_temp = self._target_temp self._target_temp = self._away_temp await self._async_control_heating(force=True) - elif preset_mode == PRESET_NONE and self._is_away: - self._is_away = False + elif preset_mode == PRESET_NONE: + self._attr_preset_mode = PRESET_NONE self._target_temp = self._saved_target_temp await self._async_control_heating(force=True) diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index c5e35ece593..b77aecee14c 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -37,7 +37,7 @@ def source_match(state, source): async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] source = config.get(CONF_SOURCE).lower() zone_entity_id = config.get(CONF_ZONE) trigger_event = config.get(CONF_EVENT) diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 1cbaea23733..6d604ec6e30 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -8,7 +8,6 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_NAME, CONF_WEBHOOK_ID, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, ) @@ -129,7 +128,7 @@ def _set_location(hass, data, location_name): data, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/geofency/translations/hu.json b/homeassistant/components/geofency/translations/hu.json index 826b943e2f8..de8f368adb3 100644 --- a/homeassistant/components/geofency/translations/hu.json +++ b/homeassistant/components/geofency/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a Geofencyben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Geofency Webhookot?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Geofency Webhookot?", "title": "A Geofency Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/geonetnz_quakes/translations/fr.json b/homeassistant/components/geonetnz_quakes/translations/fr.json index e448f9993bf..aeb3763ce46 100644 --- a/homeassistant/components/geonetnz_quakes/translations/fr.json +++ b/homeassistant/components/geonetnz_quakes/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/gios/model.py b/homeassistant/components/gios/model.py index b6ae9a9f78f..0f5d992590b 100644 --- a/homeassistant/components/gios/model.py +++ b/homeassistant/components/gios/model.py @@ -1,8 +1,8 @@ """Type definitions for GIOS integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from homeassistant.components.sensor import SensorEntityDescription diff --git a/homeassistant/components/gios/translations/fr.json b/homeassistant/components/gios/translations/fr.json index 2b02b5cfea0..af107914c06 100644 --- a/homeassistant/components/gios/translations/fr.json +++ b/homeassistant/components/gios/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration GIO\u015a pour cette station de mesure est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter au serveur GIOS", + "cannot_connect": "\u00c9chec de connexion", "invalid_sensors_data": "Donn\u00e9es des capteurs non valides pour cette station de mesure.", "wrong_station_id": "L'identifiant de la station de mesure n'est pas correct." }, "step": { "user": { "data": { - "name": "Nom de l'int\u00e9gration", + "name": "Nom", "station_id": "Identifiant de la station de mesure" }, "description": "Mettre en place l'int\u00e9gration de la qualit\u00e9 de l'air GIO\u015a (Inspection g\u00e9n\u00e9rale polonaise de la protection de l'environnement). Si vous avez besoin d'aide pour la configuration, regardez ici: https://www.home-assistant.io/integrations/gios", diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 56cd7137504..53c28fcdaae 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -74,12 +74,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class GitHubSensor(SensorEntity): """Representation of a GitHub sensor.""" + _attr_icon = "mdi:github" + def __init__(self, github_data): """Initialize the GitHub sensor.""" - self._unique_id = github_data.repository_path - self._name = None - self._state = None - self._available = False + self._attr_unique_id = github_data.repository_path self._repository_path = None self._latest_commit_message = None self._latest_commit_sha = None @@ -97,68 +96,15 @@ class GitHubSensor(SensorEntity): self._views_unique = None self._github_data = github_data - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return unique ID for the sensor.""" - return self._unique_id - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attrs = { - ATTR_PATH: self._github_data.repository_path, - ATTR_NAME: self._name, - ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, - ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, - ATTR_LATEST_RELEASE_URL: self._latest_release_url, - ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url, - ATTR_OPEN_ISSUES: self._open_issue_count, - ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url, - ATTR_OPEN_PULL_REQUESTS: self._pull_request_count, - ATTR_STARGAZERS: self._stargazers, - ATTR_FORKS: self._forks, - } - if self._latest_release_tag is not None: - attrs[ATTR_LATEST_RELEASE_TAG] = self._latest_release_tag - if self._clones is not None: - attrs[ATTR_CLONES] = self._clones - if self._clones_unique is not None: - attrs[ATTR_CLONES_UNIQUE] = self._clones_unique - if self._views is not None: - attrs[ATTR_VIEWS] = self._views - if self._views_unique is not None: - attrs[ATTR_VIEWS_UNIQUE] = self._views_unique - return attrs - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:github" - async def async_update(self): """Collect updated data from GitHub API.""" await self._github_data.async_update() - self._available = self._github_data.available - if not self._available: + self._attr_available = self._github_data.available + if not self.available: return - self._name = self._github_data.name - self._state = self._github_data.last_commit.sha[0:7] + self._attr_name = self._github_data.name + self._attr_native_value = self._github_data.last_commit.sha[0:7] self._latest_commit_message = self._github_data.last_commit.commit.message self._latest_commit_sha = self._github_data.last_commit.sha @@ -188,6 +134,32 @@ class GitHubSensor(SensorEntity): self._views = self._github_data.views_response.data.count self._views_unique = self._github_data.views_response.data.uniques + self._attr_extra_state_attributes = { + ATTR_PATH: self._github_data.repository_path, + ATTR_NAME: self.name, + ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, + ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, + ATTR_LATEST_RELEASE_URL: self._latest_release_url, + ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url, + ATTR_OPEN_ISSUES: self._open_issue_count, + ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url, + ATTR_OPEN_PULL_REQUESTS: self._pull_request_count, + ATTR_STARGAZERS: self._stargazers, + ATTR_FORKS: self._forks, + } + if self._latest_release_tag is not None: + self._attr_extra_state_attributes[ + ATTR_LATEST_RELEASE_TAG + ] = self._latest_release_tag + if self._clones is not None: + self._attr_extra_state_attributes[ATTR_CLONES] = self._clones + if self._clones_unique is not None: + self._attr_extra_state_attributes[ATTR_CLONES_UNIQUE] = self._clones_unique + if self._views is not None: + self._attr_extra_state_attributes[ATTR_VIEWS] = self._views + if self._views_unique is not None: + self._attr_extra_state_attributes[ATTR_VIEWS_UNIQUE] = self._views_unique + class GitHubData: """GitHub Data object.""" diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 491dd297a05..50f915ef4de 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -194,4 +194,16 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:docker", ), + GlancesSensorEntityDescription( + key="used", + type="raid", + name_suffix="Raid used", + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="available", + type="raid", + name_suffix="Raid available", + icon="mdi:harddisk", + ), ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 76e2a1c617a..92173f9d143 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -38,6 +38,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): description, ) ) + elif description.type == "raid": + for raid_device in client.api.data[description.type]: + dev.append(GlancesSensor(client, name, raid_device, description)) elif client.api.data[description.type]: dev.append( GlancesSensor( @@ -214,3 +217,7 @@ class GlancesSensor(SensorEntity): self._state = round(mem_use / 1024 ** 2, 1) except KeyError: self._state = STATE_UNAVAILABLE + elif self.entity_description.type == "raid": + for raid_device, raid in value["raid"].items(): + if raid_device == self._sensor_name_prefix: + self._state = raid[self.entity_description.key] diff --git a/homeassistant/components/glances/translations/fr.json b/homeassistant/components/glances/translations/fr.json index cc9be2d6ce8..6fafa8a3a51 100644 --- a/homeassistant/components/glances/translations/fr.json +++ b/homeassistant/components/glances/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "cannot_connect": "\u00c9chec de connexion", "wrong_version": "Version non prise en charge (2 ou 3 uniquement)" }, "step": { @@ -14,9 +14,9 @@ "name": "Nom", "password": "Mot de passe", "port": "Port", - "ssl": "Utiliser SSL / TLS pour se connecter au syst\u00e8me Glances", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifier la certification du syst\u00e8me", + "verify_ssl": "V\u00e9rifier le certificat SSL", "version": "Glances API Version (2 ou 3)" }, "title": "Installation de Glances" diff --git a/homeassistant/components/glances/translations/hu.json b/homeassistant/components/glances/translations/hu.json index d85baecb5ca..d93fa4bb66e 100644 --- a/homeassistant/components/glances/translations/hu.json +++ b/homeassistant/components/glances/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 04a9d6aaa86..7a179c46210 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -48,8 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] - session = async_get_clientsession(hass) - api = Yeti(host, hass.loop, session) + api = Yeti(host, async_get_clientsession(hass)) try: await api.init_connect() except exceptions.ConnectError as ex: diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index cc2c4a9874f..f192c71cbf8 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -104,14 +104,11 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _async_try_connect(self, host: str) -> tuple[str | None, str | None]: """Try connecting to Goal Zero Yeti.""" try: - session = async_get_clientsession(self.hass) - api = Yeti(host, self.hass.loop, session) + api = Yeti(host, async_get_clientsession(self.hass)) await api.sysinfo() except exceptions.ConnectError: - _LOGGER.error("Error connecting to device at %s", host) return None, "cannot_connect" except exceptions.InvalidHost: - _LOGGER.error("Invalid host at %s", host) return None, "invalid_host" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index b4a9415d01d..b19cb884353 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -3,7 +3,7 @@ "name": "Goal Zero Yeti", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/goalzero", - "requirements": ["goalzero==0.1.7"], + "requirements": ["goalzero==0.2.0"], "dhcp": [ {"hostname": "yeti*"} ], diff --git a/homeassistant/components/goalzero/translations/es.json b/homeassistant/components/goalzero/translations/es.json index 06ee47fd1ca..fa54d6d6afc 100644 --- a/homeassistant/components/goalzero/translations/es.json +++ b/homeassistant/components/goalzero/translations/es.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "La cuenta ya ha sido configurada" + "already_configured": "La cuenta ya ha sido configurada", + "invalid_host": "Nombre de host o direcci\u00f3n IP no v\u00e1lidos", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/goalzero/translations/fr.json b/homeassistant/components/goalzero/translations/fr.json index bb6a777b6be..469f37143a2 100644 --- a/homeassistant/components/goalzero/translations/fr.json +++ b/homeassistant/components/goalzero/translations/fr.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", - "unknown": "Erreur inconnue" + "unknown": "Erreur inattendue" }, "step": { "confirm_discovery": { diff --git a/homeassistant/components/goalzero/translations/hu.json b/homeassistant/components/goalzero/translations/hu.json index f8c507a6625..62c0a1626f9 100644 --- a/homeassistant/components/goalzero/translations/hu.json +++ b/homeassistant/components/goalzero/translations/hu.json @@ -12,15 +12,15 @@ }, "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.", + "description": "DHCP foglal\u00e1s aj\u00e1nlott az routeren. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az router felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", "title": "Goal Zero Yeti" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "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.", + "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 routeren. 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 router felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index c1f81f8fd32..5d190034028 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, Mapping +from collections.abc import Awaitable, Callable, Mapping from datetime import timedelta import logging -from typing import Any, Callable, NamedTuple +from typing import Any, NamedTuple from ismartgate import AbstractGateApi, GogoGate2Api, ISmartGateApi from ismartgate.common import AbstractDoor, get_door_by_id diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index a9be18d06a6..7ad248b88d6 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -121,7 +121,7 @@ class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): return TEMP_CELSIUS @property - def device_state_attributes(self): + def extra_state_attributes(self): """Return the state attributes.""" door = self._get_door() if door.sensorid is not None: diff --git a/homeassistant/components/gogogate2/translations/id.json b/homeassistant/components/gogogate2/translations/id.json index 89d25d74a48..04029205389 100644 --- a/homeassistant/components/gogogate2/translations/id.json +++ b/homeassistant/components/gogogate2/translations/id.json @@ -16,7 +16,7 @@ "username": "Nama Pengguna" }, "description": "Berikan informasi yang diperlukan di bawah ini.", - "title": "Siapkan GogoGate2 atau iSmartGate" + "title": "Siapkan GogoGate2 atau ismartgate" } } } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ebbed89347e..4e3ade38e39 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -15,10 +15,10 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, - EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, CoreState, HomeAssistant, State, callback +from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.helpers import start from homeassistant.helpers.area_registry import AreaEntry from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -105,15 +105,14 @@ class AbstractConfig(ABC): self._store = GoogleConfigStore(self.hass) await self._store.async_load() - if self.hass.state == CoreState.running: - await self.async_sync_entities_all() + if not self.enabled: return async def sync_google(_): """Sync entities to Google.""" await self.async_sync_entities_all() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, sync_google) + start.async_at_start(self.hass, sync_google) @property def enabled(self): diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 3787a63a514..61768ff2be8 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -51,7 +51,7 @@ def _get_homegraph_jwt(time, iss, key): "iat": now, "exp": now + 3600, } - return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8") + return jwt.encode(jwt_raw, key, algorithm="RS256") async def _get_homegraph_token(hass, jwt_signed): diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 393f8b22fbe..fea2ea4a310 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -614,6 +614,7 @@ class LocatorTrait(_Trait): ) +@register_trait class EnergyStorageTrait(_Trait): """Trait to offer EnergyStorage functionality. @@ -2314,10 +2315,7 @@ class SensorStateTrait(_Trait): @classmethod def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return ( - domain == sensor.DOMAIN - and device_class in SensorStateTrait.sensor_types.keys() - ) + return domain == sensor.DOMAIN and device_class in cls.sensor_types def sync_attributes(self): """Return attributes for a sync request.""" diff --git a/homeassistant/components/google_travel_time/translations/fr.json b/homeassistant/components/google_travel_time/translations/fr.json index d9c19d0d793..790fe9117bd 100644 --- a/homeassistant/components/google_travel_time/translations/fr.json +++ b/homeassistant/components/google_travel_time/translations/fr.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "api_key": "common::config_flow::data::api_key", + "api_key": "Cl\u00e9 d'API", "destination": "Destination", "name": "Nom", "origin": "Origine" diff --git a/homeassistant/components/gpmdp/media_player.py b/homeassistant/components/gpmdp/media_player.py index 5680eb75500..0a26a514323 100644 --- a/homeassistant/components/gpmdp/media_player.py +++ b/homeassistant/components/gpmdp/media_player.py @@ -1,8 +1,11 @@ """Support for Google Play Music Desktop Player.""" +from __future__ import annotations + import json import logging import socket import time +from typing import Any import voluptuous as vol from websocket import _exceptions, create_connection @@ -28,7 +31,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -_CONFIGURING = {} +_CONFIGURING: dict[str, Any] = {} _LOGGER = logging.getLogger(__name__) DEFAULT_HOST = "localhost" diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 0ec8e658867..0c475872093 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -10,7 +10,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, ) from homeassistant.helpers import config_entry_flow @@ -91,7 +90,7 @@ async def handle_webhook(hass, webhook_id, request): attrs, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/gpslogger/translations/hu.json b/homeassistant/components/gpslogger/translations/hu.json index fe459ca3164..45832cf493f 100644 --- a/homeassistant/components/gpslogger/translations/hu.json +++ b/homeassistant/components/gpslogger/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a GPSLoggerben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: `{webhook_url}` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3]({docs_url}) linken tal\u00e1lhat\u00f3k." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a GPSLogger Webhookot?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a GPSLogger Webhookot?", "title": "GPSLogger Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/gree/translations/hu.json b/homeassistant/components/gree/translations/hu.json index 6c61530acbe..a56ebbfc906 100644 --- a/homeassistant/components/gree/translations/hu.json +++ b/homeassistant/components/gree/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/gree/translations/nl.json b/homeassistant/components/gree/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/gree/translations/nl.json +++ b/homeassistant/components/gree/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 096108b460e..dad8f943328 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -56,7 +56,7 @@ ATTR_ALL = "all" SERVICE_SET = "set" SERVICE_REMOVE = "remove" -PLATFORMS = ["light", "cover", "notify"] +PLATFORMS = ["light", "cover", "notify", "binary_sensor"] REG_KEY = f"{DOMAIN}_registry" diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py new file mode 100644 index 00000000000..24d6cb86aa1 --- /dev/null +++ b/homeassistant/components/group/binary_sensor.py @@ -0,0 +1,131 @@ +"""This platform allows several binary sensor to be grouped into one binary sensor.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, + PLATFORM_SCHEMA, + BinarySensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import CoreState, Event, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType + +from . import GroupEntity + +DEFAULT_NAME = "Binary Sensor Group" + +CONF_ALL = "all" +REG_KEY = f"{BINARY_SENSOR_DOMAIN}_registry" + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(BINARY_SENSOR_DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ALL): cv.boolean, + } +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the Group Binary Sensor platform.""" + async_add_entities( + [ + BinarySensorGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config.get(CONF_DEVICE_CLASS), + config[CONF_ENTITIES], + config.get(CONF_ALL), + ) + ] + ) + + +class BinarySensorGroup(GroupEntity, BinarySensorEntity): + """Representation of a BinarySensorGroup.""" + + def __init__( + self, + unique_id: str | None, + name: str, + device_class: str | None, + entity_ids: list[str], + mode: str | None, + ) -> None: + """Initialize a BinarySensorGroup entity.""" + super().__init__() + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + self._device_class = device_class + self._state: str | None = None + self.mode = any + if mode: + self.mode = all + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + async def async_state_changed_listener(event: Event) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + await self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + if self.hass.state == CoreState.running: + await self.async_update() + return + + await super().async_added_to_hass() + + async def async_update(self) -> None: + """Query all members and determine the binary sensor group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + filtered_states: list[str] = [x.state for x in all_states if x is not None] + self._attr_available = any( + state != STATE_UNAVAILABLE for state in filtered_states + ) + if STATE_UNAVAILABLE in filtered_states: + self._attr_is_on = None + else: + states = list(map(lambda x: x == STATE_ON, filtered_states)) + state = self.mode(states) + self._attr_is_on = state + self.async_write_ha_state() + + @property + def device_class(self) -> str | None: + """Return the sensor class of the binary sensor.""" + return self._device_class diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 3870ad3cca5..45d88a07f88 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -37,6 +37,7 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, + STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, @@ -85,7 +86,7 @@ async def async_setup_platform( class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" - _attr_is_closed: bool | None = False + _attr_is_closed: bool | None = None _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False _attr_current_cover_position: int | None = 100 @@ -258,7 +259,7 @@ class CoverGroup(GroupEntity, CoverEntity): """Update state and attributes.""" self._attr_assumed_state = False - self._attr_is_closed = True + self._attr_is_closed = None self._attr_is_closing = False self._attr_is_opening = False for entity_id in self._entities: @@ -268,6 +269,9 @@ class CoverGroup(GroupEntity, CoverEntity): if state.state == STATE_OPEN: self._attr_is_closed = False continue + if state.state == STATE_CLOSED: + self._attr_is_closed = True + continue if state.state == STATE_CLOSING: self._attr_is_closing = True continue diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 810959609b5..844e6e3799f 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,7 +1,8 @@ """This platform allows several media players to be grouped into one media player.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import voluptuous as vol @@ -205,7 +206,7 @@ class MediaGroup(MediaPlayerEntity): return False @property - def device_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict: """Return the state attributes for the media group.""" return {ATTR_ENTITY_ID: self._entities} diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index 0ca969e6812..798a8e1e7c6 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -4,7 +4,7 @@ "closed": "\u05e1\u05d2\u05d5\u05e8", "home": "\u05d1\u05d1\u05d9\u05ea", "locked": "\u05e0\u05e2\u05d5\u05dc", - "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "not_home": "\u05d1\u05d7\u05d5\u05e5", "off": "\u05db\u05d1\u05d5\u05d9", "ok": "\u05ea\u05e7\u05d9\u05df", "on": "\u05de\u05d5\u05e4\u05e2\u05dc", diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py index 7e284691049..0944ceb6745 100644 --- a/homeassistant/components/group/util.py +++ b/homeassistant/components/group/util.py @@ -1,9 +1,9 @@ """Utility functions to combine state attributes from multiple entities.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from itertools import groupby -from typing import Any, Callable +from typing import Any from homeassistant.core import State diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index c4a97a81f0a..11f082f1eab 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -6,7 +6,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback -from .const import CONF_PLANT_ID, DEFAULT_URL, DOMAIN, SERVER_URLS +from .const import ( + CONF_PLANT_ID, + DEFAULT_URL, + DOMAIN, + LOGIN_INVALID_AUTH_CODE, + SERVER_URLS, +) class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -45,7 +51,10 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) - if not login_response["success"] and login_response["errCode"] == "102": + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): return self._async_show_user_form({"base": "invalid_auth"}) self.user_id = login_response["user"]["id"] diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index e0297de5eff..5425e26c806 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -16,3 +16,5 @@ DEFAULT_URL = SERVER_URLS[0] DOMAIN = "growatt_server" PLATFORMS = ["sensor"] + +LOGIN_INVALID_AUTH_CODE = "502" diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index f2eea640e99..804d4157543 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -5,11 +5,11 @@ from dataclasses import dataclass import datetime import json import logging -import re import growattServer from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_URL, CONF_USERNAME, - CURRENCY_EURO, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -38,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL, LOGIN_INVALID_AUTH_CODE _LOGGER = logging.getLogger(__name__) @@ -57,6 +56,7 @@ class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKey """Describes Growatt sensor entity.""" precision: int | None = None + currency: bool = False TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( @@ -64,13 +64,13 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="total_money_today", name="Total money today", api_key="plantMoneyText", - native_unit_of_measurement=CURRENCY_EURO, + currency=True, ), GrowattSensorEntityDescription( key="total_money_total", name="Money lifetime", api_key="totalMoneyText", - native_unit_of_measurement=CURRENCY_EURO, + currency=True, ), GrowattSensorEntityDescription( key="total_energy_today", @@ -92,7 +92,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="totalEnergy", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="total_maximum_output", @@ -119,7 +119,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, precision=1, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="inverter_voltage_input_1", @@ -273,7 +273,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eacTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, precision=1, ), GrowattSensorEntityDescription( @@ -282,6 +282,15 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epv1Total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_today_input_1", + name="Energy Today Input 1", + api_key="epv1Today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, precision=1, ), @@ -315,6 +324,15 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epv2Total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_today_input_2", + name="Energy Today Input 2", + api_key="epv2Today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, precision=1, ), @@ -429,7 +447,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_grid_discharge_today", @@ -451,7 +469,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eopDischrTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_grid_charged_today", @@ -466,7 +484,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_solar_production", @@ -522,7 +540,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eToUserTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="storage_load_consumption", @@ -641,7 +659,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_battery_discharge_today", @@ -656,7 +674,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="eBatDisChargeTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_solar_generation_today", @@ -671,7 +689,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="epvTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_battery_discharge_w", @@ -715,7 +733,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="elocalLoadTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), GrowattSensorEntityDescription( key="mix_export_to_grid_today", @@ -730,7 +748,7 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="etogridTotal", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, ), # Values from 'mix_system_status' API call GrowattSensorEntityDescription( @@ -858,7 +876,10 @@ 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": + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): _LOGGER.error("Username, Password or URL may be incorrect!") return user_id = login_response["user"]["id"] @@ -957,6 +978,13 @@ class GrowattInverter(SensorEntity): result = round(result, self.entity_description.precision) return result + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + if self.entity_description.currency: + return self.probe.get_data("currency") + return super().native_unit_of_measurement + def update(self): """Get the latest data from the Growat API and updates the state.""" self.probe.update() @@ -985,10 +1013,10 @@ class GrowattData: if self.growatt_type == "total": total_info = self.api.plant_info(self.device_id) del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" remove anything that isn't part of the number - total_info["plantMoneyText"] = re.sub( - r"[^\d.,]", "", total_info["plantMoneyText"] - ) + # PlantMoneyText comes in as "3.1/€" split between value and currency + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency self.data = total_info elif self.growatt_type == "inverter": inverter_info = self.api.inverter_detail(self.device_id) diff --git a/homeassistant/components/growatt_server/translations/es.json b/homeassistant/components/growatt_server/translations/es.json index 23860f225da..8fe4ae8b791 100644 --- a/homeassistant/components/growatt_server/translations/es.json +++ b/homeassistant/components/growatt_server/translations/es.json @@ -17,6 +17,7 @@ "data": { "name": "Nombre", "password": "Nombre", + "url": "URL", "username": "Usuario" }, "title": "Introduce tu informaci\u00f3n de Growatt." diff --git a/homeassistant/components/growatt_server/translations/fr.json b/homeassistant/components/growatt_server/translations/fr.json index 1ad47166f8d..939111c4151 100644 --- a/homeassistant/components/growatt_server/translations/fr.json +++ b/homeassistant/components/growatt_server/translations/fr.json @@ -4,7 +4,7 @@ "no_plants": "Aucune plante n'a \u00e9t\u00e9 trouv\u00e9e sur ce compte" }, "error": { - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "plant": { diff --git a/homeassistant/components/growatt_server/translations/id.json b/homeassistant/components/growatt_server/translations/id.json index 789d4e1732b..59975607fb7 100644 --- a/homeassistant/components/growatt_server/translations/id.json +++ b/homeassistant/components/growatt_server/translations/id.json @@ -8,6 +8,7 @@ "data": { "name": "Nama", "password": "Kata Sandi", + "url": "URL", "username": "Nama Pengguna" } } diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f97bc9796ec..9450c717148 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -1,11 +1,12 @@ """Support for GTFS (Google/General Transport Format Schema).""" from __future__ import annotations +from collections.abc import Callable import datetime import logging import os import threading -from typing import Any, Callable +from typing import Any import pygtfs from sqlalchemy.sql import text diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index fb7952669cc..16b05e20767 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,11 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -41,6 +45,7 @@ SENSOR_DESCRIPTION_TEMPERATURE = SensorEntityDescription( name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_FAHRENHEIT, + state_class=STATE_CLASS_MEASUREMENT, ) SENSOR_DESCRIPTION_UPTIME = SensorEntityDescription( key=SENSOR_KIND_UPTIME, diff --git a/homeassistant/components/guardian/translations/fr.json b/homeassistant/components/guardian/translations/fr.json index 62ffae35776..e1e1bcb4fcf 100644 --- a/homeassistant/components/guardian/translations/fr.json +++ b/homeassistant/components/guardian/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce p\u00e9riph\u00e9rique Guardian a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9.", - "already_in_progress": "La configuration de l'appareil Guardian est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion" }, "step": { diff --git a/homeassistant/components/guardian/translations/hu.json b/homeassistant/components/guardian/translations/hu.json index 15469bead1e..ecd1b7de01b 100644 --- a/homeassistant/components/guardian/translations/hu.json +++ b/homeassistant/components/guardian/translations/hu.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { "discovery_confirm": { - "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" }, "user": { "data": { @@ -17,7 +17,7 @@ "description": "Konfigur\u00e1lja a helyi Elexa Guardian eszk\u00f6zt." }, "zeroconf_confirm": { - "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" } } } diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index c4d0e0be4d7..d83334e7a40 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import timedelta -from typing import Any, Callable, Dict, cast +from typing import Any, Dict, cast from aioguardian import Client from aioguardian.errors import GuardianError diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index eb42426e8ea..ae27e0a51fc 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -26,7 +26,7 @@ SENSORS_TYPES = { "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), - "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), + "gp": ST("Gold", "mdi:circle-multiple", "Gold", ["stats", "gp"]), "class": ST("Class", "mdi:sword", "", ["stats", "class"]), } diff --git a/homeassistant/components/hangouts/translations/fi.json b/homeassistant/components/hangouts/translations/fi.json index 959a2c06a63..05b394c69f4 100644 --- a/homeassistant/components/hangouts/translations/fi.json +++ b/homeassistant/components/hangouts/translations/fi.json @@ -8,6 +8,7 @@ "data": { "2fa": "2FA-pin" }, + "description": "Tyhj\u00e4", "title": "Kaksivaiheinen tunnistus" }, "user": { @@ -15,6 +16,7 @@ "email": "S\u00e4hk\u00f6postiosoite", "password": "Salasana" }, + "description": "Tyhj\u00e4", "title": "Google Hangouts -kirjautuminen" } } diff --git a/homeassistant/components/hangouts/translations/fr.json b/homeassistant/components/hangouts/translations/fr.json index 68e652db309..ab2d2fc5168 100644 --- a/homeassistant/components/hangouts/translations/fr.json +++ b/homeassistant/components/hangouts/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Google Hangouts est d\u00e9j\u00e0 configur\u00e9", - "unknown": "Une erreur inconnue s'est produite" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" }, "error": { "invalid_2fa": "Authentification \u00e0 2 facteurs invalide, veuillez r\u00e9essayer.", @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Code d'autorisation (requis pour l'authentification manuelle)", - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "description": "Vide", diff --git a/homeassistant/components/hangouts/translations/hu.json b/homeassistant/components/hangouts/translations/hu.json index 2f02ba9f623..eda0144a818 100644 --- a/homeassistant/components/hangouts/translations/hu.json +++ b/homeassistant/components/hangouts/translations/hu.json @@ -12,7 +12,7 @@ "step": { "2fa": { "data": { - "2fa": "2FA Pin" + "2fa": "2FA PIN" }, "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 0d8d893a98e..c8e15ed0b0f 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -2,7 +2,7 @@ DOMAIN = "harmony" SERVICE_SYNC = "sync" SERVICE_CHANGE_CHANNEL = "change_channel" -PLATFORMS = ["remote", "switch"] +PLATFORMS = ["remote", "switch", "select"] UNIQUE_ID = "unique_id" ACTIVITY_POWER_OFF = "PowerOff" HARMONY_OPTIONS_UPDATE = "harmony_options_update" diff --git a/homeassistant/components/harmony/connection_state.py b/homeassistant/components/harmony/entity.py similarity index 70% rename from homeassistant/components/harmony/connection_state.py rename to homeassistant/components/harmony/entity.py index 84ad353480c..24c72a771e7 100644 --- a/homeassistant/components/harmony/connection_state.py +++ b/homeassistant/components/harmony/entity.py @@ -1,20 +1,31 @@ -"""Mixin class for handling connection state changes.""" +"""Base class Harmony entities.""" import logging +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later +from .data import HarmonyData + _LOGGER = logging.getLogger(__name__) TIME_MARK_DISCONNECTED = 10 -class ConnectionStateMixin: - """Base implementation for connection state handling.""" +class HarmonyEntity(Entity): + """Base entity for Harmony with connection state handling.""" - def __init__(self): - """Initialize this mixin instance.""" + def __init__(self, data: HarmonyData) -> None: + """Initialize the Harmony base entity.""" super().__init__() self._unsub_mark_disconnected = None + self._name = data.name + self._data = data + self._attr_should_poll = False + + @property + def available(self) -> bool: + """Return True if we're connected to the Hub, otherwise False.""" + return self._data.available async def async_got_connected(self, _=None): """Notification that we're connected to the HUB.""" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index e28d525539b..f35f4e99303 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -3,7 +3,13 @@ "name": "Logitech Harmony Hub", "documentation": "https://www.home-assistant.io/integrations/harmony", "requirements": ["aioharmony==0.2.7"], - "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"], + "codeowners": [ + "@ehendrix23", + "@bramkragten", + "@bdraco", + "@mkeesey", + "@Aohzan" + ], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 593fbf3cb22..806b638aee8 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -21,7 +21,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity -from .connection_state import ConnectionStateMixin from .const import ( ACTIVITY_POWER_OFF, ATTR_ACTIVITY_STARTING, @@ -34,6 +33,7 @@ from .const import ( SERVICE_CHANGE_CHANNEL, SERVICE_SYNC, ) +from .entity import HarmonyEntity from .subscriber import HarmonyCallback _LOGGER = logging.getLogger(__name__) @@ -76,28 +76,24 @@ async def async_setup_entry( ) -class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): +class HarmonyRemote(HarmonyEntity, remote.RemoteEntity, RestoreEntity): """Remote representation used to control a Harmony device.""" def __init__(self, data, activity, delay_secs, out_path): """Initialize HarmonyRemote class.""" - super().__init__() - self._data = data - self._name = data.name + super().__init__(data=data) self._state = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None self._is_initial_update = True self.delay_secs = delay_secs - self._unique_id = data.unique_id self._last_activity = None self._config_path = out_path - - @property - def supported_features(self): - """Supported features for the remote.""" - return SUPPORT_ACTIVITY + self._attr_unique_id = data.unique_id + self._attr_device_info = self._data.device_info(DOMAIN) + self._attr_name = data.name + self._attr_supported_features = SUPPORT_ACTIVITY async def _async_update_options(self, data): """Change options when the options flow does.""" @@ -128,7 +124,7 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): """Complete the initialization.""" await super().async_added_to_hass() - _LOGGER.debug("%s: Harmony Hub added", self._name) + _LOGGER.debug("%s: Harmony Hub added", self.name) self.async_on_remove(self._clear_disconnection_delay) self._setup_callbacks() @@ -158,26 +154,6 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): self._last_activity = last_state.attributes[ATTR_LAST_ACTIVITY] - @property - def device_info(self): - """Return device info.""" - return self._data.device_info(DOMAIN) - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the Harmony device's name.""" - return self._name - - @property - def should_poll(self): - """Return the fact that we should not be polled.""" - return False - @property def current_activity(self): """Return the current activity.""" @@ -202,16 +178,11 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, "PowerOff"] - @property - def available(self): - """Return True if connected to Hub, otherwise False.""" - return self._data.available - @callback def async_new_activity(self, activity_info: tuple) -> None: """Call for updating the current activity.""" activity_id, activity_name = activity_info - _LOGGER.debug("%s: activity reported as: %s", self._name, activity_name) + _LOGGER.debug("%s: activity reported as: %s", self.name, activity_name) self._current_activity = activity_name if self._is_initial_update: self._is_initial_update = False @@ -227,7 +198,7 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): async def async_new_config(self, _=None): """Call for updating the current activity.""" - _LOGGER.debug("%s: configuration has been updated", self._name) + _LOGGER.debug("%s: configuration has been updated", self.name) self.async_new_activity(self._data.current_activity) await self.hass.async_add_executor_job(self.write_config_file) diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py new file mode 100644 index 00000000000..18f273e4bfb --- /dev/null +++ b/homeassistant/components/harmony/select.py @@ -0,0 +1,75 @@ +"""Support for Harmony Hub select activities.""" +from __future__ import annotations + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import ACTIVITY_POWER_OFF, DOMAIN, HARMONY_DATA +from .data import HarmonyData +from .entity import HarmonyEntity +from .subscriber import HarmonyCallback + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up harmony activities select.""" + data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + _LOGGER.debug("creating select for %s hub activities", entry.data[CONF_NAME]) + async_add_entities( + [HarmonyActivitySelect(f"{entry.data[CONF_NAME]} Activities", data)] + ) + + +class HarmonyActivitySelect(HarmonyEntity, SelectEntity): + """Select representation of a Harmony activities.""" + + def __init__(self, name: str, data: HarmonyData) -> None: + """Initialize HarmonyActivitySelect class.""" + super().__init__(data=data) + self._data = data + self._attr_unique_id = self._data.unique_id + self._attr_device_info = self._data.device_info(DOMAIN) + self._attr_name = name + + @property + def icon(self): + """Return a representative icon.""" + if not self.available or self.current_option == ACTIVITY_POWER_OFF: + return "mdi:remote-tv-off" + return "mdi:remote-tv" + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return [ACTIVITY_POWER_OFF] + sorted(self._data.activity_names) + + @property + def current_option(self): + """Return the current activity.""" + _, activity_name = self._data.current_activity + return activity_name + + async def async_select_option(self, option: str) -> None: + """Change the current activity.""" + await self._data.async_start_activity(option) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + + callbacks = { + "connected": self.async_got_connected, + "disconnected": self.async_got_disconnected, + "activity_starting": self._async_activity_update, + "activity_started": self._async_activity_update, + "config_updated": None, + } + + self.async_on_remove(self._data.async_subscribe(HarmonyCallback(**callbacks))) + + @callback + def _async_activity_update(self, activity_info: tuple): + self.async_write_ha_state() diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a45b43fce0f..02885289a06 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -5,9 +5,9 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME from homeassistant.core import callback -from .connection_state import ConnectionStateMixin from .const import DOMAIN, HARMONY_DATA from .data import HarmonyData +from .entity import HarmonyEntity from .subscriber import HarmonyCallback _LOGGER = logging.getLogger(__name__) @@ -27,31 +27,18 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(switches, True) -class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): +class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): """Switch representation of a Harmony activity.""" def __init__(self, name: str, activity: dict, data: HarmonyData) -> None: """Initialize HarmonyActivitySwitch class.""" - super().__init__() - self._name = name + super().__init__(data=data) self._activity_name = activity["label"] self._activity_id = activity["id"] - self._data = data - - @property - def name(self): - """Return the Harmony activity's name.""" - return self._name - - @property - def unique_id(self): - """Return the unique id.""" - return f"activity_{self._activity_id}" - - @property - def device_info(self): - """Return device info.""" - return self._data.device_info(DOMAIN) + self._attr_entity_registry_enabled_default = False + self._attr_unique_id = f"activity_{self._activity_id}" + self._attr_name = name + self._attr_device_info = self._data.device_info(DOMAIN) @property def is_on(self): @@ -59,16 +46,6 @@ class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): _, activity_name = self._data.current_activity return activity_name == self._activity_name - @property - def should_poll(self): - """Return that we shouldn't be polled.""" - return False - - @property - def available(self): - """Return True if we're connected to the Hub, otherwise False.""" - return self._data.available - async def async_turn_on(self, **kwargs): """Start this activity.""" await self._data.async_start_activity(self._activity_name) diff --git a/homeassistant/components/harmony/translations/fr.json b/homeassistant/components/harmony/translations/fr.json index 4343ec3139d..25b9e24eb5f 100644 --- a/homeassistant/components/harmony/translations/fr.json +++ b/homeassistant/components/harmony/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "flow_title": "Logitech Harmony Hub {name}", @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom du Hub" }, "title": "Configuration de Logitech Harmony Hub" diff --git a/homeassistant/components/harmony/translations/hu.json b/homeassistant/components/harmony/translations/hu.json index 4922bbd1ac6..900cd243247 100644 --- a/homeassistant/components/harmony/translations/hu.json +++ b/homeassistant/components/harmony/translations/hu.json @@ -10,12 +10,12 @@ "flow_title": "{name}", "step": { "link": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "Hub neve" }, "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" diff --git a/homeassistant/components/harmony/translations/id.json b/homeassistant/components/harmony/translations/id.json index 0d2991b1feb..86ab0be3274 100644 --- a/homeassistant/components/harmony/translations/id.json +++ b/homeassistant/components/harmony/translations/id.json @@ -7,7 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Logitech Harmony Hub {name}", + "flow_title": "{name}", "step": { "link": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d55de8e275b..eacf5be5f9f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging import os -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol @@ -132,38 +132,59 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( ) +class APIEndpointSettings(NamedTuple): + """Settings for API endpoint.""" + + command: str + schema: vol.Schema + timeout: int | None = 60 + pass_data: bool = False + + MAP_SERVICE_API = { - SERVICE_ADDON_START: ("/addons/{addon}/start", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STOP: ("/addons/{addon}/stop", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_RESTART: ("/addons/{addon}/restart", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_UPDATE: ("/addons/{addon}/update", SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), - SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), - SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), - SERVICE_BACKUP_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), - SERVICE_BACKUP_PARTIAL: ( - "/backups/new/partial", - SCHEMA_BACKUP_PARTIAL, - 300, + SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), + SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), + SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), + SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON), + SERVICE_ADDON_STDIN: APIEndpointSettings( + "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN + ), + SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA), + SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA), + SERVICE_BACKUP_FULL: APIEndpointSettings( + "/backups/new/full", + SCHEMA_BACKUP_FULL, + None, True, ), - SERVICE_RESTORE_FULL: ( + SERVICE_BACKUP_PARTIAL: APIEndpointSettings( + "/backups/new/partial", + SCHEMA_BACKUP_PARTIAL, + None, + True, + ), + SERVICE_RESTORE_FULL: APIEndpointSettings( "/backups/{slug}/restore/full", SCHEMA_RESTORE_FULL, - 300, + None, True, ), - SERVICE_RESTORE_PARTIAL: ( + SERVICE_RESTORE_PARTIAL: APIEndpointSettings( "/backups/{slug}/restore/partial", SCHEMA_RESTORE_PARTIAL, - 300, + None, True, ), - SERVICE_SNAPSHOT_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: ( + SERVICE_SNAPSHOT_FULL: APIEndpointSettings( + "/backups/new/full", + SCHEMA_BACKUP_FULL, + None, + True, + ), + SERVICE_SNAPSHOT_PARTIAL: APIEndpointSettings( "/backups/new/partial", SCHEMA_BACKUP_PARTIAL, - 300, + None, True, ), } @@ -397,7 +418,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) if not await hassio.is_connected(): - _LOGGER.warning("Not connected with Hass.io / system too busy!") + _LOGGER.warning("Not connected with the supervisor / system too busy!") store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) data = await store.async_load() @@ -466,7 +487,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service): """Handle service calls for Hass.io.""" - api_command = MAP_SERVICE_API[service.service][0] + api_endpoint = MAP_SERVICE_API[service.service] + if "snapshot" in service.service: _LOGGER.warning( "The service '%s' is deprecated and will be removed in Home Assistant 2021.11, use '%s' instead", @@ -488,22 +510,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Pass data to Hass.io API if service.service == SERVICE_ADDON_STDIN: payload = data[ATTR_INPUT] - elif MAP_SERVICE_API[service.service][3]: + elif api_endpoint.pass_data: payload = data # Call API try: await hassio.send_command( - api_command.format(addon=addon, slug=slug), + api_endpoint.command.format(addon=addon, slug=slug), payload=payload, - timeout=MAP_SERVICE_API[service.service][2], + timeout=api_endpoint.timeout, ) - except HassioAPIError as err: - _LOGGER.error("Error on Supervisor API: %s", err) + except HassioAPIError: + # The exceptions are logged properly in hassio.send_command + pass for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings[1] + DOMAIN, service, async_service_handler, schema=settings.schema ) async def update_info_data(now): diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 38d78984ddc..3e5736c3593 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -72,8 +72,8 @@ snapshot_full: fields: name: name: Name - description: Optional or it will be the current date and time. - example: "backup 1" + description: Optional (default = current date and time). + example: "Backup 1" selector: text: password: @@ -89,7 +89,7 @@ snapshot_partial: fields: addons: name: Add-ons - description: Optional list of addon slugs. + description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: @@ -101,7 +101,7 @@ snapshot_partial: object: name: name: Name - description: Optional or it will be the current date and time. + description: Optional (default = current date and time). example: "Partial backup 1" selector: text: @@ -118,8 +118,8 @@ backup_full: fields: name: name: Name - description: Optional or it will be the current date and time. - example: "backup 1" + description: Optional (default = current date and time). + example: "Backup 1" selector: text: password: @@ -135,7 +135,7 @@ backup_partial: fields: addons: name: Add-ons - description: Optional list of addon slugs. + description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: @@ -147,7 +147,7 @@ backup_partial: object: name: name: Name - description: Optional or it will be the current date and time. + description: Optional (default = current date and time). example: "Partial backup 1" selector: text: @@ -157,3 +157,41 @@ backup_partial: example: "password" selector: text: + +restore_full: + name: Restore from full backup. + description: Restore from full backup. + fields: + slug: + name: Slug + description: Slug of backup to restore from. + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +restore_partial: + name: Restore from partial backup. + description: Restore from partial backup. + fields: + homeassistant: + name: Home Assistant settings + description: Restore Home Assistant + selector: + boolean: + folders: + name: Folders + description: Optional list of directories. + example: ["homeassistant", "share"] + selector: + object: + addons: + name: Add-ons + description: Optional list of add-on slugs. + example: ["core_ssh", "core_samba", "core_mosquitto"] + selector: + object: diff --git a/homeassistant/components/hassio/translations/he.json b/homeassistant/components/hassio/translations/he.json index 17b7fcd0050..8926338221a 100644 --- a/homeassistant/components/hassio/translations/he.json +++ b/homeassistant/components/hassio/translations/he.json @@ -1,10 +1,18 @@ { "system_health": { "info": { + "board": "\u05dc\u05d5\u05d7", + "disk_total": "\u05e1\u05d4\"\u05db \u05d3\u05d9\u05e1\u05e7", + "disk_used": "\u05d3\u05d9\u05e1\u05e7 \u05d1\u05e9\u05d9\u05de\u05d5\u05e9", + "docker_version": "\u05d2\u05d9\u05e8\u05e1\u05ea Docker", + "healthy": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea", "host_os": "\u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4 \u05de\u05d0\u05e8\u05d7\u05ea", + "installed_addons": "\u05d4\u05e8\u05d7\u05d1\u05d5\u05ea \u05de\u05d5\u05ea\u05e7\u05e0\u05d5\u05ea", "supervisor_api": "API \u05e9\u05dc \u05de\u05e4\u05e7\u05d7", "supervisor_version": "\u05d2\u05d9\u05e8\u05e1\u05ea \u05de\u05e4\u05e7\u05d7", - "update_channel": "\u05e2\u05e8\u05d5\u05e5 \u05e2\u05d3\u05db\u05d5\u05df" + "supported": "\u05e0\u05ea\u05de\u05da", + "update_channel": "\u05e2\u05e8\u05d5\u05e5 \u05e2\u05d3\u05db\u05d5\u05df", + "version_api": "\u05d2\u05e8\u05e1\u05ea API" } } } \ No newline at end of file diff --git a/homeassistant/components/heos/translations/hu.json b/homeassistant/components/heos/translations/hu.json index c487b49ee47..8996c2a4530 100644 --- a/homeassistant/components/heos/translations/hu.json +++ b/homeassistant/components/heos/translations/hu.json @@ -9,9 +9,9 @@ "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "K\u00e9rj\u00fck, adja meg egy Heos-eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t (lehet\u0151leg egy vezet\u00e9kkel a h\u00e1l\u00f3zathoz csatlakoztatott eszk\u00f6zt).", + "description": "K\u00e9rj\u00fck, adja meg egy Heos-eszk\u00f6z hosztnev\u00e9t vagy c\u00edm\u00e9t (lehet\u0151leg egy vezet\u00e9kkel a h\u00e1l\u00f3zathoz csatlakoztatott eszk\u00f6zt).", "title": "Csatlakoz\u00e1s a Heos-hoz" } } diff --git a/homeassistant/components/hisense_aehw4a1/translations/fr.json b/homeassistant/components/hisense_aehw4a1/translations/fr.json index 7fa1598fa76..72d3eec98dd 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/fr.json +++ b/homeassistant/components/hisense_aehw4a1/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique AEH-W4A1 trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de AEH-W4A1 est possible." + "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": { diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 518e555c280..7c3087d471f 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt, timedelta +from http import HTTPStatus import logging import time from typing import cast @@ -19,13 +20,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.components.recorder.util import session_scope -from homeassistant.const import ( - CONF_DOMAINS, - CONF_ENTITIES, - CONF_EXCLUDE, - CONF_INCLUDE, - HTTP_BAD_REQUEST, -) +from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import deprecated_class, deprecated_function @@ -59,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema( @deprecated_function("homeassistant.components.recorder.history.get_significant_states") def get_significant_states(hass, *args, **kwargs): - """Wrap _get_significant_states with an sql session.""" + """Wrap get_significant_states_with_session with an sql session.""" return history.get_significant_states(hass, *args, **kwargs) @@ -103,7 +98,7 @@ async def async_setup(hass, config): hass.http.register_view(HistoryPeriodView(filters, use_include_order)) hass.components.frontend.async_register_built_in_panel( - "history", "history", "hass:poll-box" + "history", "history", "hass:chart-box" ) hass.components.websocket_api.async_register_command( ws_get_statistics_during_period @@ -124,6 +119,7 @@ class LazyState(history_models.LazyState): vol.Required("start_time"): str, vol.Optional("end_time"): str, vol.Optional("statistic_ids"): [str], + vol.Required("period"): vol.Any("hour", "5minute"), } ) @websocket_api.async_response @@ -157,6 +153,7 @@ async def ws_get_statistics_during_period( start_time, end_time, msg.get("statistic_ids"), + msg.get("period"), ) connection.send_result(msg["id"], statistics) @@ -201,7 +198,7 @@ class HistoryPeriodView(HomeAssistantView): datetime_ = dt_util.parse_datetime(datetime) if datetime_ is None: - return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) + return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) now = dt_util.utcnow() @@ -220,7 +217,7 @@ class HistoryPeriodView(HomeAssistantView): if end_time: end_time = dt_util.as_utc(end_time) else: - return self.json_message("Invalid end_time", HTTP_BAD_REQUEST) + return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: end_time = start_time + one_day entity_ids_str = request.query.get("filter_entity_id") @@ -271,18 +268,16 @@ class HistoryPeriodView(HomeAssistantView): timer_start = time.perf_counter() with session_scope(hass=hass) as session: - result = ( - history._get_significant_states( # pylint: disable=protected-access - hass, - session, - start_time, - end_time, - entity_ids, - self.filters, - include_start_time_state, - significant_changes_only, - minimal_response, - ) + result = history.get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + self.filters, + include_start_time_state, + significant_changes_only, + minimal_response, ) result = list(result.values()) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 7639a07c82a..80a6bb0941e 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -194,7 +194,7 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Return the current preset mode, e.g., home, away, temp.""" if self.device["status"]["boost"] == "ON": return PRESET_BOOST - return None + return PRESET_NONE @property def preset_modes(self): diff --git a/homeassistant/components/hive/translations/ca.json b/homeassistant/components/hive/translations/ca.json index eacccda82e7..edebafba579 100644 --- a/homeassistant/components/hive/translations/ca.json +++ b/homeassistant/components/hive/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown_entry": "No s'ha pogut trobar l'entrada existent." }, diff --git a/homeassistant/components/hive/translations/hu.json b/homeassistant/components/hive/translations/hu.json index ce07abcb338..469b99debe1 100644 --- a/homeassistant/components/hive/translations/hu.json +++ b/homeassistant/components/hive/translations/hu.json @@ -17,7 +17,7 @@ "data": { "2fa": "K\u00e9tfaktoros k\u00f3d" }, - "description": "Add meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", + "description": "Adja meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", "title": "Hive k\u00e9tfaktoros hiteles\u00edt\u00e9s." }, "reauth": { @@ -25,7 +25,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg \u00fajra a Hive bejelentkez\u00e9si adatait.", + "description": "Adja meg \u00fajra a Hive bejelentkez\u00e9si adatait.", "title": "Hive Bejelentkez\u00e9s" }, "user": { @@ -34,7 +34,7 @@ "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.", + "description": "Adja meg a Hive bejelentkez\u00e9si adatait \u00e9s konfigur\u00e1ci\u00f3j\u00e1t.", "title": "Hive Bejelentkez\u00e9s" } } diff --git a/homeassistant/components/hlk_sw16/translations/hu.json b/homeassistant/components/hlk_sw16/translations/hu.json index 0abcc301f0c..9590d3c12be 100644 --- a/homeassistant/components/hlk_sw16/translations/hu.json +++ b/homeassistant/components/hlk_sw16/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/home_connect/translations/fr.json b/homeassistant/components/home_connect/translations/fr.json index 42a0c34fe81..5eba6fa03c1 100644 --- a/homeassistant/components/home_connect/translations/fr.json +++ b/homeassistant/components/home_connect/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "missing_configuration": "Le composant Home Connect n'est pas configur\u00e9. Veuillez suivre la documentation.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )" }, "create_entry": { - "default": "Authentification r\u00e9ussie avec Home Connect." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } } diff --git a/homeassistant/components/home_connect/translations/hu.json b/homeassistant/components/home_connect/translations/hu.json index aa43f65b520..ca5f3e1e9ae 100644 --- a/homeassistant/components/home_connect/translations/hu.json +++ b/homeassistant/components/home_connect/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, 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." }, "create_entry": { diff --git a/homeassistant/components/home_plus_control/translations/ca.json b/homeassistant/components/home_plus_control/translations/ca.json index 90e23fcd7ab..6e6dc1e0577 100644 --- a/homeassistant/components/home_plus_control/translations/ca.json +++ b/homeassistant/components/home_plus_control/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/home_plus_control/translations/fr.json b/homeassistant/components/home_plus_control/translations/fr.json index c39d4a2867e..489e0499324 100644 --- a/homeassistant/components/home_plus_control/translations/fr.json +++ b/homeassistant/components/home_plus_control/translations/fr.json @@ -1,19 +1,19 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "authorize_url_timeout": "[%key::common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "Le composant n'est pas configur\u00e9. Merci de suivre la documentation.", - "no_url_available": "Aucune URL disponible. Pour plus d'information sur cette erreur, [v\u00e9rifier la section d'aide]({docs_url})", - "single_instance_allowed": "[%key::common::config_flow::abort::single_instance_allowed%]" + "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.", + "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "create_entry": { "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisir une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } }, diff --git a/homeassistant/components/home_plus_control/translations/hu.json b/homeassistant/components/home_plus_control/translations/hu.json index 7bc04beb057..2dc22c7a729 100644 --- a/homeassistant/components/home_plus_control/translations/hu.json +++ b/homeassistant/components/home_plus_control/translations/hu.json @@ -2,9 +2,9 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "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\u00e9rem, 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.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 47dc5317bbd..b0d817478dc 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, template @@ -35,15 +38,13 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict[str, Any], + automation_info: AutomationTriggerInfo, *, platform_type: str = "event", ) -> CALLBACK_TYPE: """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - variables = None - if automation_info: - variables = automation_info.get("variables") + trigger_data = automation_info["trigger_data"] + variables = automation_info["variables"] template.attach(hass, config[CONF_EVENT_TYPE]) event_types = template.render_complex( diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index ea1a985139f..6f2ec75e313 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -20,7 +20,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] event = config.get(CONF_EVENT) job = HassJob(action) diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index f315addb272..3f280f581b3 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -78,10 +78,8 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - _variables: dict = {} - if automation_info: - _variables = automation_info.get("variables") or {} + trigger_data = automation_info["trigger_data"] + _variables = automation_info["variables"] or {} if value_template is not None: value_template.hass = hass diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 12c42a95978..f60071d633c 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -87,10 +87,8 @@ async def async_attach_trigger( attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action) - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - _variables: dict = {} - if automation_info: - _variables = automation_info.get("variables") or {} + trigger_data = automation_info["trigger_data"] + _variables = automation_info["variables"] or {} @callback def state_automation_listener(event: Event): diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index f661ae21a5b..6ca1998a5c3 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -39,7 +39,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] entities = {} removes = [] job = HassJob(action) diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 0380e01c239..000d73b6cd1 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -57,7 +57,7 @@ TRIGGER_SCHEMA = vol.All( async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 19298a9f814..8fc68ca641c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -103,9 +103,9 @@ from .const import ( from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, + async_port_is_available, dismiss_setup_message, get_persist_fullpath_for_entry_id, - port_is_available, remove_state_files_for_entry_id, show_setup_message, state_needs_accessory_mode, @@ -330,7 +330,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: logged_shutdown_wait = False for _ in range(0, SHUTDOWN_TIMEOUT): - if await hass.async_add_executor_job(port_is_available, entry.data[CONF_PORT]): + if async_port_is_available(entry.data[CONF_PORT]): break if not logged_shutdown_wait: @@ -519,7 +519,7 @@ class HomeKit: self.bridge = None self.driver = None - def setup(self, async_zeroconf_instance): + def setup(self, async_zeroconf_instance, uuid): """Set up bridge and accessory driver.""" persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) @@ -534,6 +534,7 @@ class HomeKit: persist_file=persist_file, advertised_address=self._advertise_ip, async_zeroconf_instance=async_zeroconf_instance, + zeroconf_server=f"{uuid}-hap.local.", ) # If we do not load the mac address will be wrong @@ -553,7 +554,7 @@ class HomeKit: acc = self.driver.accessory if acc.entity_id not in entity_ids: return - acc.async_stop() + await acc.stop() if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( "The underlying entity %s disappeared during reset", acc.entity @@ -576,7 +577,7 @@ class HomeKit: self._name, entity_id, ) - acc = self.remove_bridge_accessory(aid) + acc = await self.async_remove_bridge_accessory(aid) if state := self.hass.states.get(acc.entity_id): new.append(state) else: @@ -669,11 +670,11 @@ class HomeKit: ) ) - def remove_bridge_accessory(self, aid): + async def async_remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" acc = self.bridge.accessories.pop(aid, None) if acc: - acc.async_stop() + await acc.stop() return acc async def async_configure_accessories(self): @@ -713,7 +714,8 @@ class HomeKit: return self.status = STATUS_WAIT async_zc_instance = await zeroconf.async_get_async_instance(self.hass) - await self.hass.async_add_executor_job(self.setup, async_zc_instance) + uuid = await self.hass.helpers.instance_id.async_get() + await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) await self.aid_storage.async_initialize() if not await self._async_create_accessories(): @@ -864,11 +866,6 @@ class HomeKit: self.status = STATUS_STOPPED _LOGGER.debug("Driver stop for %s", self._name) await self.driver.async_stop() - if self.bridge: - for acc in self.bridge.accessories.values(): - acc.async_stop() - else: - self.driver.accessory.async_stop() @callback def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): @@ -966,6 +963,7 @@ class HomeKitPairingQRView(HomeAssistantView): async def get(self, request): """Retrieve the pairing QRCode image.""" + # pylint: disable=no-self-use if not request.query_string: raise Unauthorized() entry_id, secret = request.query_string.split("-") diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8298cdd9c83..3b7f2c0f9eb 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -198,6 +198,9 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): a_type = "Switch" + elif state.domain in ("input_select", "select"): + a_type = "SelectSwitch" + elif state.domain == "water_heater": a_type = "WaterHeater" @@ -496,8 +499,7 @@ class HomeAccessory(Accessory): ) ) - @ha_callback - def async_stop(self): + async def stop(self): """Cancel any subscriptions when the bridge is stopped.""" while self._subscriptions: self._subscriptions.pop(0)() diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 03df55a9026..79fc43fde3a 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, + CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, DEFAULT_AUTO_START, DEFAULT_CONFIG_FLOW_PORT, @@ -54,6 +55,7 @@ from .const import ( ) from .util import async_find_next_available_port, state_needs_accessory_mode +CONF_CAMERA_AUDIO = "camera_audio" CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" @@ -84,6 +86,7 @@ SUPPORTED_DOMAINS = [ "fan", "humidifier", "input_boolean", + "input_select", "light", "lock", MEDIA_PLAYER_DOMAIN, @@ -91,6 +94,7 @@ SUPPORTED_DOMAINS = [ REMOTE_DOMAIN, "scene", "script", + "select", "sensor", "switch", "vacuum", @@ -170,9 +174,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_pairing(self, user_input=None): """Pairing instructions.""" if user_input is not None: - port = await async_find_next_available_port( - self.hass, DEFAULT_CONFIG_FLOW_PORT - ) + port = async_find_next_available_port(self.hass, DEFAULT_CONFIG_FLOW_PORT) await self._async_add_entries_for_accessory_mode_entities(port) self.hk_data[CONF_PORT] = port include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] @@ -203,7 +205,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entity_id in accessory_mode_entity_ids: if entity_id in exiting_entity_ids_accessory_mode: continue - port = await async_find_next_available_port(self.hass, next_port_to_check) + port = async_find_next_available_port(self.hass, next_port_to_check) next_port_to_check = port + 1 self.hass.async_create_task( self.hass.config_entries.flow.async_init( @@ -362,14 +364,24 @@ class OptionsFlowHandler(config_entries.OptionsFlow): and CONF_VIDEO_CODEC in entity_config[entity_id] ): del entity_config[entity_id][CONF_VIDEO_CODEC] + if entity_id in user_input[CONF_CAMERA_AUDIO]: + entity_config.setdefault(entity_id, {})[CONF_SUPPORT_AUDIO] = True + elif ( + entity_id in entity_config + and CONF_SUPPORT_AUDIO in entity_config[entity_id] + ): + del entity_config[entity_id][CONF_SUPPORT_AUDIO] return await self.async_step_advanced() + cameras_with_audio = [] cameras_with_copy = [] entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: hk_entity_config = entity_config.get(entity, {}) if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: cameras_with_copy.append(entity) + if hk_entity_config.get(CONF_SUPPORT_AUDIO): + cameras_with_audio.append(entity) data_schema = vol.Schema( { @@ -377,6 +389,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_CAMERA_COPY, default=cameras_with_copy, ): cv.multi_select(self.included_cameras), + vol.Optional( + CONF_CAMERA_AUDIO, + default=cameras_with_audio, + ): cv.multi_select(self.included_cameras), } ) return self.async_show_form(step_id="cameras", data_schema=data_schema) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index e40d743068c..2589a1ac6ec 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==4.1.0", + "HAP-python==4.2.1", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 69cff3bfcc3..ede11aef19c 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -23,10 +23,11 @@ }, "cameras": { "data": { - "camera_copy": "Cameras that support native H.264 streams" + "camera_copy": "Cameras that support native H.264 streams", + "camera_audio": "Cameras that support audio" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Select camera video codec." + "title": "Camera Configuration" }, "advanced": { "data": { diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json index d6bb88f1dba..63f34999a4d 100644 --- a/homeassistant/components/homekit/translations/ca.json +++ b/homeassistant/components/homekit/translations/ca.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "Inici autom\u00e0tic (desactiva-ho si crides el servei homekit.start manualment)" + "auto_start": "Inici autom\u00e0tic (desactiva-ho si crides el servei homekit.start manualment)", + "devices": "Dispositius (disparadors)" }, - "description": "Aquests par\u00e0metres nom\u00e9s s'han d'ajustar si HomeKit no \u00e9s funcional.", + "description": "Els interruptors programables es creen per cada dispositiu seleccionat. HomeKit pot ser programat per a que executi una automatitzaci\u00f3 o escena quan un dispositiu es dispari.", "title": "Configuraci\u00f3 avan\u00e7ada" }, "cameras": { "data": { + "camera_audio": "C\u00e0meres que admeten \u00e0udio", "camera_copy": "C\u00e0meres que admeten fluxos H.264 natius" }, "description": "Comprova les c\u00e0meres que suporten fluxos nadius H.264. Si alguna c\u00e0mera not proporciona una sortida H.264, el sistema transcodificar\u00e0 el v\u00eddeo a H.264 per a HomeKit. La transcodificaci\u00f3 necessita una CPU potent i probablement no funcioni en ordinadors petits (SBC).", - "title": "Selecci\u00f3 del c\u00f2dec de v\u00eddeo de c\u00e0mera" + "title": "Configuraci\u00f3 de c\u00e0mera" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/de.json b/homeassistant/components/homekit/translations/de.json index 759a33fca91..a0c407c454e 100644 --- a/homeassistant/components/homekit/translations/de.json +++ b/homeassistant/components/homekit/translations/de.json @@ -21,13 +21,15 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (deaktivieren, wenn du den homekit.start-Dienst manuell aufrufst)" + "auto_start": "Autostart (deaktivieren, wenn du den homekit.start-Dienst manuell aufrufst)", + "devices": "Ger\u00e4te (Trigger)" }, - "description": "Diese Einstellungen m\u00fcssen nur angepasst werden, wenn HomeKit nicht funktioniert.", + "description": "F\u00fcr jedes ausgew\u00e4hlte Ger\u00e4t werden programmierbare Schalter erstellt. Wenn ein Ger\u00e4teausl\u00f6ser ausgel\u00f6st wird, kann HomeKit so konfiguriert werden, dass eine Automatisierung oder Szene ausgef\u00fchrt wird.", "title": "Erweiterte Konfiguration" }, "cameras": { "data": { + "camera_audio": "Kameras, die Audio unterst\u00fctzen", "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.", diff --git a/homeassistant/components/homekit/translations/el.json b/homeassistant/components/homekit/translations/el.json new file mode 100644 index 00000000000..58d7a62bc59 --- /dev/null +++ b/homeassistant/components/homekit/translations/el.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u03a3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 (\u0395\u03bd\u03b1\u03cd\u03c3\u03bc\u03b1\u03c4\u03b1)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index 564709cb9c1..b118ec16424 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -29,10 +29,11 @@ }, "cameras": { "data": { + "camera_audio": "Cameras that support audio", "camera_copy": "Cameras that support native H.264 streams" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Select camera video codec." + "title": "Camera Configuration" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/es.json b/homeassistant/components/homekit/translations/es.json index e713391eb9e..6008d399d64 100644 --- a/homeassistant/components/homekit/translations/es.json +++ b/homeassistant/components/homekit/translations/es.json @@ -5,7 +5,7 @@ }, "step": { "pairing": { - "description": "Tan pronto como la pasarela {name} est\u00e9 lista, la vinculaci\u00f3n estar\u00e1 disponible en \"Notificaciones\" como \"configuraci\u00f3n de pasarela Homekit\"", + "description": "Para completar el emparejamiento, sigue las instrucciones en \"Notificaciones\" en \"Emparejamiento HomeKit\".", "title": "Vincular pasarela Homekit" }, "user": { @@ -21,13 +21,15 @@ "step": { "advanced": { "data": { - "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)" + "auto_start": "Arranque autom\u00e1tico (desactivado si se utiliza Z-Wave u otro sistema de arranque retardado)", + "devices": "Dispositivos (disparadores)" }, "description": "Esta configuraci\u00f3n solo necesita ser ajustada si el puente HomeKit no es funcional.", "title": "Configuraci\u00f3n avanzada" }, "cameras": { "data": { + "camera_audio": "C\u00e1maras que admiten audio", "camera_copy": "C\u00e1maras compatibles con transmisiones H.264 nativas" }, "description": "Verifique todas las c\u00e1maras que admiten transmisiones H.264 nativas. Si la c\u00e1mara no emite una transmisi\u00f3n H.264, el sistema transcodificar\u00e1 el video a H.264 para HomeKit. La transcodificaci\u00f3n requiere una CPU de alto rendimiento y es poco probable que funcione en ordenadores de placa \u00fanica.", diff --git a/homeassistant/components/homekit/translations/et.json b/homeassistant/components/homekit/translations/et.json index 1213200474a..cd02425a2b6 100644 --- a/homeassistant/components/homekit/translations/et.json +++ b/homeassistant/components/homekit/translations/et.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (keela kui kasutad homekit.start teenust k\u00e4sitsi)" + "auto_start": "Autostart (keela kui kasutad homekit.start teenust k\u00e4sitsi)", + "devices": "Seadmed (p\u00e4\u00e4stikud)" }, - "description": "Neid s\u00e4tteid tuleb muuta ainult siis kui HomeKit ei t\u00f6\u00f6ta.", + "description": "Iga valitud seadme jaoks luuakse programmeeritavad l\u00fclitid. Seadme p\u00e4\u00e4stiku k\u00e4ivitamisel saab HomeKiti seadistada automaatiseeringu v\u00f5i stseeni k\u00e4ivitamiseks.", "title": "T\u00e4psem seadistamine" }, "cameras": { "data": { + "camera_audio": "Heliedastusega kaamerad", "camera_copy": "Kaamerad, mis toetavad riistvaralist H.264 voogu" }, "description": "Vali k\u00f5iki kaameraid, mis toetavad kohalikku H.264 voogu. Kui kaamera ei edasta H.264 voogu, kodeerib s\u00fcsteem video HomeKiti jaoks versioonile H.264. \u00dcmberkodeerimine n\u00f5uab j\u00f5udsat protsessorit ja t\u00f5en\u00e4oliselt ei t\u00f6\u00f6ta see \u00fcheplaadilistes arvutites.", - "title": "Vali kaamera videokoodek." + "title": "Kaamera s\u00e4tted" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/fr.json b/homeassistant/components/homekit/translations/fr.json index 018e93e18c9..ef931792193 100644 --- a/homeassistant/components/homekit/translations/fr.json +++ b/homeassistant/components/homekit/translations/fr.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)" + "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", + "devices": "P\u00e9riph\u00e9riques (d\u00e9clencheurs)" }, "description": "Ces param\u00e8tres ne doivent \u00eatre ajust\u00e9s que si le pont HomeKit n'est pas fonctionnel.", "title": "Configuration avanc\u00e9e" diff --git a/homeassistant/components/homekit/translations/he.json b/homeassistant/components/homekit/translations/he.json index 789298b7705..320bf203044 100644 --- a/homeassistant/components/homekit/translations/he.json +++ b/homeassistant/components/homekit/translations/he.json @@ -10,6 +10,14 @@ }, "options": { "step": { + "advanced": { + "data": { + "devices": "\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd (\u05d8\u05e8\u05d9\u05d2\u05e8\u05d9\u05dd)" + } + }, + "cameras": { + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05de\u05e6\u05dc\u05de\u05d4" + }, "include_exclude": { "data": { "mode": "\u05de\u05e6\u05d1" diff --git a/homeassistant/components/homekit/translations/hu.json b/homeassistant/components/homekit/translations/hu.json index c6fdf0afd74..046cf57e9b9 100644 --- a/homeassistant/components/homekit/translations/hu.json +++ b/homeassistant/components/homekit/translations/hu.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)" + "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)", + "devices": "Eszk\u00f6z\u00f6k (triggerek)" }, - "description": "Ezeket a be\u00e1ll\u00edt\u00e1sokat csak akkor kell m\u00f3dos\u00edtani, ha a HomeKit nem m\u0171k\u00f6dik.", + "description": "Programozhat\u00f3 kapcsol\u00f3k j\u00f6nnek l\u00e9tre minden kiv\u00e1lasztott eszk\u00f6zh\u00f6z. Amikor egy eszk\u00f6z esem\u00e9nyt ind\u00edt el, a HomeKit be\u00e1ll\u00edthat\u00f3 \u00fagy, hogy egy automatizmus vagy egy jelenet induljon el.", "title": "Halad\u00f3 be\u00e1ll\u00edt\u00e1sok" }, "cameras": { "data": { - "camera_copy": "A nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" + "camera_audio": "Hangot t\u00e1mogat\u00f3 kamer\u00e1k", + "camera_copy": "Nat\u00edv H.264 streameket t\u00e1mogat\u00f3 kamer\u00e1k" }, "description": "Ellen\u0151rizze az \u00f6sszes kamer\u00e1t, amely t\u00e1mogatja a nat\u00edv H.264 adatfolyamokat. Ha a f\u00e9nyk\u00e9pez\u0151g\u00e9p nem ad ki H.264 adatfolyamot, a rendszer \u00e1tk\u00f3dolja a vide\u00f3t H.264 form\u00e1tumba a HomeKit sz\u00e1m\u00e1ra. Az \u00e1tk\u00f3dol\u00e1shoz nagy teljes\u00edtm\u00e9ny\u0171 CPU sz\u00fcks\u00e9ges, \u00e9s val\u00f3sz\u00edn\u0171leg nem fog m\u0171k\u00f6dni egylapos sz\u00e1m\u00edt\u00f3g\u00e9peken.", - "title": "V\u00e1laszd ki a kamera vide\u00f3 kodekj\u00e9t." + "title": "V\u00e1lassza ki a kamera vide\u00f3 kodekj\u00e9t." }, "include_exclude": { "data": { @@ -39,7 +41,7 @@ "mode": "M\u00f3d" }, "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s szerepel. H\u00eddbefogad\u00e1si m\u00f3dban a tartom\u00e1ny \u00f6sszes entit\u00e1sa szerepelni fog, hacsak nincsenek kijel\u00f6lve konkr\u00e9t entit\u00e1sok. H\u00eddkiz\u00e1r\u00e1si m\u00f3dban a domain \u00f6sszes entit\u00e1sa szerepelni fog, kiv\u00e9ve a kiz\u00e1rt entit\u00e1sokat. A legjobb teljes\u00edtm\u00e9ny \u00e9rdek\u00e9ben minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9galap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez k\u00fcl\u00f6n HomeKit tartoz\u00e9kot hoznak l\u00e9tre.", - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" + "title": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt entit\u00e1sokat" }, "init": { "data": { @@ -47,7 +49,7 @@ "mode": "M\u00f3d" }, "description": "A HomeKit konfigur\u00e1lhat\u00f3 \u00fagy, hogy egy h\u00edd vagy egyetlen tartoz\u00e9k l\u00e1that\u00f3 legyen. Kieg\u00e9sz\u00edt\u0151 m\u00f3dban csak egyetlen entit\u00e1s haszn\u00e1lhat\u00f3. A tartoz\u00e9k m\u00f3dra van sz\u00fcks\u00e9g ahhoz, hogy a TV -eszk\u00f6zoszt\u00e1ly\u00fa m\u00e9dialej\u00e1tsz\u00f3k megfelel\u0151en m\u0171k\u00f6djenek. A \u201eTartalmazand\u00f3 tartom\u00e1nyok\u201d entit\u00e1sai szerepelni fognak a HomeKitben. A k\u00f6vetkez\u0151 k\u00e9perny\u0151n kiv\u00e1laszthatja, hogy mely entit\u00e1sokat k\u00edv\u00e1nja felvenni vagy kiz\u00e1rni a list\u00e1b\u00f3l.", - "title": "V\u00e1laszd ki a felvenni k\u00edv\u00e1nt domaineket." + "title": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt domaineket." }, "yaml": { "description": "Ez a bejegyz\u00e9s YAML-en kereszt\u00fcl vez\u00e9relhet\u0151", diff --git a/homeassistant/components/homekit/translations/id.json b/homeassistant/components/homekit/translations/id.json index ecb35196228..64ce23a5224 100644 --- a/homeassistant/components/homekit/translations/id.json +++ b/homeassistant/components/homekit/translations/id.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domain yang disertakan" }, - "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan. Instans HomeKit terpisah dalam mode aksesori akan dibuat untuk setiap pemutar media TV dan kamera.", + "description": "Pilih domain yang akan disertakan. Semua entitas yang didukung di domain akan disertakan. Instans HomeKit terpisah dalam mode aksesori akan dibuat untuk setiap pemutar media TV, remote berbasis aktivitas, kunci, dan kamera.", "title": "Pilih domain yang akan disertakan" } } @@ -23,7 +23,7 @@ "data": { "auto_start": "Mulai otomatis (nonaktifkan jika Anda memanggil layanan homekit.start secara manual)" }, - "description": "Pengaturan ini hanya perlu disesuaikan jika HomeKit tidak berfungsi.", + "description": "Sakelar yang dapat diprogram dibuat untuk setiap perangkat yang dipilih. Saat pemicu perangkat aktif, HomeKit dapat dikonfigurasi untuk menjalankan otomatisasi atau scene.", "title": "Konfigurasi Tingkat Lanjut" }, "cameras": { @@ -31,14 +31,14 @@ "camera_copy": "Kamera yang mendukung aliran H.264 asli" }, "description": "Periksa semua kamera yang mendukung streaming H.264 asli. Jika kamera tidak mengeluarkan aliran H.264, sistem akan mentranskode video ke H.264 untuk HomeKit. Proses transcoding membutuhkan CPU kinerja tinggi dan tidak mungkin bekerja pada komputer papan tunggal.", - "title": "Pilih codec video kamera." + "title": "Konfigurasi Kamera" }, "include_exclude": { "data": { "entities": "Entitas", "mode": "Mode" }, - "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan. Dalam mode \"bridge include\", semua entitas di domain akan disertakan, kecuali entitas tertentu dipilih. Dalam mode \"bridge exclude\", semua entitas di domain akan disertakan, kecuali untuk entitas tertentu yang dipilih. Untuk kinerja terbaik, aksesori HomeKit terpisah diperlukan untuk masing-masing pemutar media, TV, dan kamera.", + "description": "Pilih entitas yang akan disertakan. Dalam mode aksesori, hanya satu entitas yang disertakan. Dalam mode \"bridge include\", semua entitas di domain akan disertakan, kecuali entitas tertentu dipilih. Dalam mode \"bridge exclude\", semua entitas di domain akan disertakan, kecuali untuk entitas tertentu yang dipilih. Untuk kinerja terbaik, aksesori HomeKit terpisah diperlukan untuk masing-masing pemutar media TV, remote berbasis aktivitas, kunci, dan kamera.", "title": "Pilih entitas untuk disertakan" }, "init": { diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index c9afa13fb85..8e7ead91ac1 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "Avvio automatico (disabilitare se stai chiamando manualmente il servizio homekit.start)" + "auto_start": "Avvio automatico (disabilitare se stai chiamando manualmente il servizio homekit.start)", + "devices": "Dispositivi (Attivatori)" }, - "description": "Queste impostazioni devono essere regolate solo se HomeKit non funziona.", + "description": "Gli interruttori programmabili vengono creati per ogni dispositivo selezionato. Quando si attiva un trigger del dispositivo, HomeKit pu\u00f2 essere configurato per eseguire un'automazione o una scena.", "title": "Configurazione Avanzata" }, "cameras": { "data": { + "camera_audio": "Telecamere che supportano l'audio", "camera_copy": "Telecamere che supportano flussi H.264 nativi" }, "description": "Controllare tutte le telecamere che supportano i flussi H.264 nativi. Se la videocamera non emette uno stream H.264, il sistema provveder\u00e0 a transcodificare il video in H.264 per HomeKit. La transcodifica richiede una CPU performante ed \u00e8 improbabile che funzioni su computer a scheda singola.", - "title": "Seleziona il codec video della videocamera." + "title": "Configurazione della telecamera" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index 368005985bf..2ab21f66db5 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (deactiveer als je de homekit.start service handmatig aanroept)" + "auto_start": "Autostart (deactiveer als je de homekit.start service handmatig aanroept)", + "devices": "Apparaten (triggers)" }, "description": "Deze instellingen hoeven alleen te worden aangepast als HomeKit niet functioneert.", "title": "Geavanceerde configuratie" }, "cameras": { "data": { + "camera_audio": "Camera's die audio ondersteunen", "camera_copy": "Camera's die native H.264-streams ondersteunen" }, "description": "Controleer alle camera's die native H.264-streams ondersteunen. Als de camera geen H.264-stream uitvoert, transcodeert het systeem de video naar H.264 voor HomeKit. Transcodering vereist een performante CPU en het is onwaarschijnlijk dat dit werkt op computers met \u00e9\u00e9n bord.", - "title": "Selecteer de videocodec van de camera." + "title": "Cameraconfiguratie" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 2a4f1497e2f..86e5c8d95cb 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (deaktiver hvis du ringer til homekit.start-tjenesten manuelt)" + "auto_start": "Autostart (deaktiver hvis du ringer til homekit.start-tjenesten manuelt)", + "devices": "Enheter (utl\u00f8sere)" }, - "description": "Disse innstillingene m\u00e5 bare justeres hvis HomeKit ikke fungerer.", + "description": "Programmerbare brytere opprettes for hver valgt enhet. N\u00e5r en enhetstrigger utl\u00f8ses, kan HomeKit konfigureres til \u00e5 kj\u00f8re en automatisering eller scene.", "title": "Avansert konfigurasjon" }, "cameras": { "data": { + "camera_audio": "Kameraer som st\u00f8tter lyd", "camera_copy": "Kameraer som st\u00f8tter opprinnelige H.264-str\u00f8mmer" }, "description": "Sjekk alle kameraer som st\u00f8tter opprinnelige H.264-str\u00f8mmer. Hvis kameraet ikke sender ut en H.264-str\u00f8m, vil systemet omkode videoen til H.264 for HomeKit. Transkoding krever en potent prosessor og er usannsynlig \u00e5 fungere p\u00e5 enkeltkortdatamaskiner som Raspberry Pi o.l.", - "title": "Velg videokodek for kamera." + "title": "Kamerakonfigurasjon" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index 15ccb10e118..300c730e66c 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli r\u0119cznie uruchamiasz us\u0142ug\u0119 homekit.start)" + "auto_start": "Automatyczne uruchomienie (wy\u0142\u0105cz, je\u015bli r\u0119cznie uruchamiasz us\u0142ug\u0119 homekit.start)", + "devices": "Urz\u0105dzenia (Wyzwalacze)" }, - "description": "Te ustawienia nale\u017cy dostosowa\u0107 tylko wtedy, gdy HomeKit nie dzia\u0142a.", + "description": "Dla ka\u017cdego wybranego urz\u0105dzenia stworzony zostanie programowalny prze\u0142\u0105cznik. Po uruchomieniu wyzwalacza urz\u0105dzenia, HomeKit mo\u017cna skonfigurowa\u0107 do uruchamiania automatyzacji lub sceny.", "title": "Konfiguracja zaawansowana" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/ru.json b/homeassistant/components/homekit/translations/ru.json index 6b85983073a..f871636df00 100644 --- a/homeassistant/components/homekit/translations/ru.json +++ b/homeassistant/components/homekit/translations/ru.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u0412\u044b \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442\u0435 \u0441\u043b\u0443\u0436\u0431\u0443 homekit.start)" + "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u0412\u044b \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442\u0435 \u0441\u043b\u0443\u0436\u0431\u0443 homekit.start)", + "devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 (\u0442\u0440\u0438\u0433\u0433\u0435\u0440\u044b)" }, - "description": "\u042d\u0442\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b, \u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438 HomeKit \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442.", + "description": "\u041f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u044b\u0435 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u0438 \u0441\u043e\u0437\u0434\u0430\u044e\u0442\u0441\u044f \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. HomeKit \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0434\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0438 \u0438\u043b\u0438 \u0441\u0446\u0435\u043d\u044b, \u043a\u043e\u0433\u0434\u0430 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442 \u0442\u0440\u0438\u0433\u0433\u0435\u0440 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", "title": "\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" }, "cameras": { "data": { - "camera_copy": "\u041a\u0430\u043c\u0435\u0440\u044b, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442 \u043f\u043e\u0442\u043e\u043a\u0438 H.264" + "camera_audio": "\u041a\u0430\u043c\u0435\u0440\u044b \u0441 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u043e\u0439 \u0430\u0443\u0434\u0438\u043e", + "camera_copy": "\u041a\u0430\u043c\u0435\u0440\u044b \u0441 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u043e\u0439 H.264" }, "description": "\u0415\u0441\u043b\u0438 \u043a\u0430\u043c\u0435\u0440\u0430 \u043d\u0435 \u0432\u044b\u0432\u043e\u0434\u0438\u0442 \u043f\u043e\u0442\u043e\u043a H.264, \u0441\u0438\u0441\u0442\u0435\u043c\u0430 \u043f\u0435\u0440\u0435\u043a\u043e\u0434\u0438\u0440\u0443\u0435\u0442 \u0432\u0438\u0434\u0435\u043e \u0432 H.264 \u0434\u043b\u044f HomeKit. \u0422\u0440\u0430\u043d\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0432\u044b\u0441\u043e\u043a\u043e\u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0430 \u0438 \u0432\u0440\u044f\u0434 \u043b\u0438 \u0431\u0443\u0434\u0435\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043d\u0430 \u043e\u0434\u043d\u043e\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430\u0445.", - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0432\u0438\u0434\u0435\u043e\u043a\u043e\u0434\u0435\u043a \u043a\u0430\u043c\u0435\u0440\u044b." + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043a\u0430\u043c\u0435\u0440\u044b" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/te.json b/homeassistant/components/homekit/translations/te.json new file mode 100644 index 00000000000..3ad5c6451b1 --- /dev/null +++ b/homeassistant/components/homekit/translations/te.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u0c2a\u0c30\u0c3f\u0c15\u0c30\u0c3e\u0c32\u0c41 (\u0c1f\u0c4d\u0c30\u0c3f\u0c17\u0c4d\u0c17\u0c30\u0c4d\u0c32\u0c41)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/th.json b/homeassistant/components/homekit/translations/th.json new file mode 100644 index 00000000000..50e648c9e6e --- /dev/null +++ b/homeassistant/components/homekit/translations/th.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "advanced": { + "data": { + "devices": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c (\u0e17\u0e23\u0e34\u0e01\u0e40\u0e01\u0e2d\u0e23\u0e4c)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/zh-Hans.json b/homeassistant/components/homekit/translations/zh-Hans.json index e85c492d3cd..4a1486735ff 100644 --- a/homeassistant/components/homekit/translations/zh-Hans.json +++ b/homeassistant/components/homekit/translations/zh-Hans.json @@ -21,7 +21,8 @@ "step": { "advanced": { "data": { - "auto_start": "[%key_id:43661779%]" + "auto_start": "[%key_id:43661779%]", + "devices": "\u8bbe\u5907 (\u89e6\u53d1\u5668)" }, "description": "\u8fd9\u4e9b\u8bbe\u7f6e\u53ea\u6709\u5f53 HomeKit \u529f\u80fd\u4e0d\u6b63\u5e38\u65f6\u624d\u9700\u8981\u8c03\u6574\u3002", "title": "\u9ad8\u7ea7\u914d\u7f6e" diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json index b6389567969..ba1cd8adf88 100644 --- a/homeassistant/components/homekit/translations/zh-Hant.json +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -21,17 +21,19 @@ "step": { "advanced": { "data": { - "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u624b\u52d5\u4f7f\u7528 homekit.start \u670d\u52d9\u6642\u3001\u8acb\u95dc\u9589\uff09" + "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u624b\u52d5\u4f7f\u7528 homekit.start \u670d\u52d9\u6642\u3001\u8acb\u95dc\u9589\uff09", + "devices": "\u88dd\u7f6e\uff08\u89f8\u767c\u5668\uff09" }, - "description": "\u50c5\u65bc Homekit \u7121\u6cd5\u6b63\u5e38\u4f7f\u7528\u6642\uff0c\u8abf\u6574\u6b64\u4e9b\u8a2d\u5b9a\u3002", + "description": "\u70ba\u6240\u9078\u64c7\u7684\u88dd\u7f6e\u65b0\u589e\u53ef\u7a0b\u5f0f\u958b\u95dc\u3002\u7576\u88dd\u7f6e\u89f8\u767c\u5668\u89f8\u767c\u6642\u3001Homekit \u53ef\u8a2d\u5b9a\u70ba\u57f7\u884c\u81ea\u52d5\u5316\u6216\u5834\u666f\u3002", "title": "\u9032\u968e\u8a2d\u5b9a" }, "cameras": { "data": { + "camera_audio": "\u652f\u63f4\u97f3\u6548\u8f38\u51fa\u651d\u5f71\u6a5f", "camera_copy": "\u652f\u63f4\u539f\u751f H.264 \u4e32\u6d41\u651d\u5f71\u6a5f" }, "description": "\u6aa2\u67e5\u6240\u6709\u652f\u63f4\u539f\u751f H.264 \u4e32\u6d41\u4e4b\u651d\u5f71\u6a5f\u3002\u5047\u5982\u651d\u5f71\u6a5f\u4e0d\u652f\u63f4 H.264 \u4e32\u6d41\u3001\u7cfb\u7d71\u5c07\u6703\u91dd\u5c0d Homekit \u9032\u884c H.264 \u8f49\u78bc\u3002\u8f49\u78bc\u5c07\u9700\u8981\u4f7f\u7528 CPU \u9032\u884c\u904b\u7b97\u3001\u55ae\u6676\u7247\u96fb\u8166\u53ef\u80fd\u6703\u906d\u9047\u6548\u80fd\u554f\u984c\u3002", - "title": "\u9078\u64c7\u651d\u5f71\u6a5f\u7de8\u78bc\u3002" + "title": "\u651d\u5f71\u6a5f\u8a2d\u5b9a" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4a8999ede08..2cdcd600932 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -244,17 +244,21 @@ class Camera(HomeAccessory, PyhapCamera): Run inside the Home Assistant event loop. """ if self._char_motion_detected: - async_track_state_change_event( - self.hass, - [self.linked_motion_sensor], - self._async_update_motion_state_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_motion_sensor], + self._async_update_motion_state_event, + ) ) if self._char_doorbell_detected: - async_track_state_change_event( - self.hass, - [self.linked_doorbell_sensor], - self._async_update_doorbell_state_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_doorbell_sensor], + self._async_update_doorbell_state_event, + ) ) await super().run() @@ -321,8 +325,6 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.exception( "Failed to get stream source - this could be a transient error or your camera might not be compatible with HomeKit yet" ) - if stream_source: - self.config[CONF_STREAM_SOURCE] = stream_source return stream_source async def start_stream(self, session_info, stream_config): @@ -436,6 +438,12 @@ class Camera(HomeAccessory, PyhapCamera): self.sessions[session_id].pop(FFMPEG_WATCHER)() self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() + async def stop(self): + """Stop any streams when the accessory is stopped.""" + for session_info in self.sessions.values(): + asyncio.create_task(self.stop_stream(session_info)) + await super().stop() + async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 4c501208ca5..0c889d9aee4 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -122,10 +122,12 @@ class GarageDoorOpener(HomeAccessory): Run inside the Home Assistant event loop. """ if self.linked_obstruction_sensor: - async_track_state_change_event( - self.hass, - [self.linked_obstruction_sensor], - self._async_update_obstruction_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_obstruction_sensor], + self._async_update_obstruction_event, + ) ) await super().run() diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 85157dd9367..d25f197e0ca 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -219,7 +219,7 @@ class Fan(HomeAccessory): # the rotation speed is mapped to 1 otherwise the update is ignored # in order to avoid this incorrect behavior. if percentage == 0 and state == STATE_ON: - percentage = 1 + percentage = max(1, self.char_speed.properties[PROP_MIN_STEP]) if percentage is not None: self.char_speed.set_value(percentage) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index 6371f883b09..2f4866e395a 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -149,10 +149,12 @@ class HumidifierDehumidifier(HomeAccessory): Run inside the Home Assistant event loop. """ if self.linked_humidity_sensor: - async_track_state_change_event( - self.hass, - [self.linked_humidity_sensor], - self.async_update_current_humidity_event, + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self.async_update_current_humidity_event, + ) ) await super().run() diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 3bb496a2abc..4e76b0369fe 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -9,6 +9,7 @@ from pyhap.const import ( CATEGORY_SWITCH, ) +from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.switch import DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -33,9 +34,11 @@ from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, CHAR_IN_USE, + CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, CHAR_VALVE_TYPE, + MAX_NAME_LENGTH, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -226,3 +229,47 @@ class Valve(HomeAccessory): self.char_active.set_value(current_state) _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) self.char_in_use.set_value(current_state) + + +@TYPES.register("SelectSwitch") +class SelectSwitch(HomeAccessory): + """Generate a Switch accessory that contains multiple switches.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self.domain = split_entity_id(self.entity_id)[0] + state = self.hass.states.get(self.entity_id) + self.select_chars = {} + options = state.attributes[ATTR_OPTIONS] + for option in options: + serv_option = self.add_preload_service( + SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE] + ) + serv_option.configure_char( + CHAR_NAME, + value=f"{option}"[:MAX_NAME_LENGTH], + ) + serv_option.configure_char(CHAR_IN_USE, value=False) + self.select_chars[option] = serv_option.configure_char( + CHAR_ON, + value=False, + setter_callback=lambda value, option=option: self.select_option(option), + ) + self.set_primary_service(self.select_chars[options[0]]) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.async_update_state(state) + + def select_option(self, option): + """Set option from HomeKit.""" + _LOGGER.debug("%s: Set option to %s", self.entity_id, option) + params = {ATTR_ENTITY_ID: self.entity_id, "option": option} + self.async_call_service(self.domain, SERVICE_SELECT_OPTION, params) + + @callback + def async_update_state(self, new_state): + """Update switch state after state changed.""" + current_option = new_state.state + for option, char in self.select_chars.items(): + char.set_value(option == current_option) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 6585e9e9c4e..a5c9f3937ea 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -27,7 +27,7 @@ from homeassistant.const import ( CONF_TYPE, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR import homeassistant.util.temperature as temp_util @@ -433,34 +433,32 @@ def _get_test_socket(): return test_socket -def port_is_available(port: int) -> bool: +@callback +def async_port_is_available(port: int) -> bool: """Check to see if a port is available.""" - test_socket = _get_test_socket() try: - test_socket.bind(("", port)) + _get_test_socket().bind(("", port)) except OSError: return False - return True -async def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: +@callback +def async_find_next_available_port(hass: HomeAssistant, start_port: int) -> int: """Find the next available port not assigned to a config entry.""" exclude_ports = { entry.data[CONF_PORT] for entry in hass.config_entries.async_entries(DOMAIN) if CONF_PORT in entry.data } - - return await hass.async_add_executor_job( - _find_next_available_port, start_port, exclude_ports - ) + return _async_find_next_available_port(start_port, exclude_ports) -def _find_next_available_port(start_port: int, exclude_ports: set) -> int: +@callback +def _async_find_next_available_port(start_port: int, exclude_ports: set) -> int: """Find the next available port starting with the given port.""" test_socket = _get_test_socket() - for port in range(start_port, MAX_PORT): + for port in range(start_port, MAX_PORT + 1): if port in exclude_ports: continue try: diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 1972aadfeca..5bb7d634626 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -9,7 +9,10 @@ from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -75,12 +78,10 @@ class TriggerSource: self, config: TRIGGER_SCHEMA, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_data = ( - automation_info.get("trigger_data", {}) if automation_info else {} - ) + trigger_data = automation_info["trigger_data"] def event_handler(char): if config[CONF_SUBTYPE] != HK_TO_HA_INPUT_EVENT_VALUES[char["value"]]: @@ -260,7 +261,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device_id = config[CONF_DEVICE_ID] diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 442db645c1f..3a07ae7ec8b 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.6.2"], + "requirements": ["aiohomekit==0.6.3"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/translations/fr.json b/homeassistant/components/homekit_controller/translations/fr.json index faf970f88eb..72d8d58517f 100644 --- a/homeassistant/components/homekit_controller/translations/fr.json +++ b/homeassistant/components/homekit_controller/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.", "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "already_paired": "Cet accessoire est d\u00e9j\u00e0 associ\u00e9 \u00e0 un autre appareil. R\u00e9initialisez l\u2019accessoire et r\u00e9essayez.", "ignored_model": "La prise en charge de HomeKit pour ce mod\u00e8le est bloqu\u00e9e car une int\u00e9gration native plus compl\u00e8te est disponible.", "invalid_config_entry": "Cet appareil est pr\u00eat \u00e0 \u00eatre coupl\u00e9, mais il existe d\u00e9j\u00e0 une entr\u00e9e de configuration en conflit dans Home Assistant \u00e0 supprimer.", diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index 1ad63bfb508..7703925ae67 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -3,22 +3,22 @@ "abort": { "accessory_not_found_error": "Nem adhat\u00f3 hozz\u00e1 p\u00e1ros\u00edt\u00e1s, mert az eszk\u00f6z m\u00e1r nem tal\u00e1lhat\u00f3.", "already_configured": "A tartoz\u00e9k m\u00e1r konfigur\u00e1lva van ezzel a vez\u00e9rl\u0151vel.", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "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_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 Home Assistantban, 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.", + "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs szabad p\u00e1ros\u00edt\u00e1si t\u00e1rhelye.", "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.", "unknown_error": "Az eszk\u00f6z ismeretlen hib\u00e1t jelentett. A p\u00e1ros\u00edt\u00e1s sikertelen." }, - "flow_title": "HomeKit tartoz\u00e9k: {name}", + "flow_title": "{name}", "step": { "busy_error": { "description": "Sz\u00fcntesse meg a p\u00e1ros\u00edt\u00e1st az \u00f6sszes vez\u00e9rl\u0151n, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", @@ -33,8 +33,8 @@ "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" + "description": "A HomeKit Controller {name} n\u00e9vvel kommunik\u00e1l a helyi h\u00e1l\u00f3zaton kereszt\u00fcl, biztons\u00e1gos titkos\u00edtott kapcsolaton kereszt\u00fcl, k\u00fcl\u00f6n HomeKit vez\u00e9rl\u0151 vagy iCloud n\u00e9lk\u00fcl. A tartoz\u00e9k haszn\u00e1lat\u00e1hoz adja meg HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban). Ez a k\u00f3d \u00e1ltal\u00e1ban mag\u00e1ban az eszk\u00f6z\u00f6n vagy a csomagol\u00e1sban tal\u00e1lhat\u00f3.", + "title": "P\u00e1ros\u00edt\u00e1s egy eszk\u00f6zzel a HomeKit Accessory Protocol protokollon seg\u00edts\u00e9g\u00e9vel" }, "protocol_error": { "description": "El\u0151fordulhat, hogy a k\u00e9sz\u00fcl\u00e9k nincs p\u00e1ros\u00edt\u00e1si m\u00f3dban, \u00e9s sz\u00fcks\u00e9g lehet fizikai vagy virtu\u00e1lis gombnyom\u00e1sra. Gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy az eszk\u00f6z p\u00e1ros\u00edt\u00e1si m\u00f3dban van, vagy pr\u00f3b\u00e1lja \u00fajraind\u00edtani az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1st.", @@ -44,7 +44,7 @@ "data": { "device": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki azt az eszk\u00f6zt, amelyet p\u00e1ros\u00edtani szeretne", + "description": "A HomeKit Controller biztons\u00e1gos titkos\u00edtott kapcsolaton kereszt\u00fcl kommunik\u00e1l a helyi h\u00e1l\u00f3zaton kereszt\u00fcl, k\u00fcl\u00f6n HomeKit vez\u00e9rl\u0151 vagy iCloud n\u00e9lk\u00fcl. V\u00e1lassza ki a p\u00e1ros\u00edtani k\u00edv\u00e1nt eszk\u00f6zt:", "title": "Eszk\u00f6z kiv\u00e1laszt\u00e1sa" } } diff --git a/homeassistant/components/homekit_controller/translations/id.json b/homeassistant/components/homekit_controller/translations/id.json index 49a37d3b3fb..839169fc6a9 100644 --- a/homeassistant/components/homekit_controller/translations/id.json +++ b/homeassistant/components/homekit_controller/translations/id.json @@ -17,7 +17,7 @@ "unable_to_pair": "Gagal memasangkan, coba lagi.", "unknown_error": "Perangkat melaporkan kesalahan yang tidak diketahui. Pemasangan gagal." }, - "flow_title": "{name} lewat HomeKit Accessory Protocol", + "flow_title": "{name}", "step": { "busy_error": { "description": "Batalkan pemasangan di semua pengontrol, atau coba mulai ulang perangkat, lalu lanjutkan untuk melanjutkan pemasangan.", diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 50b9bcb2bfc..2fb23f707e3 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -238,7 +238,7 @@ class HMHub(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return "mdi:gradient" + return "mdi:gradient-vertical" def _update_hub(self, now): """Retrieve latest state.""" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 5cccc9a9999..a3537bff31b 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from homematicip.aio.auth import AsyncAuth from homematicip.aio.home import AsyncHome @@ -59,6 +60,7 @@ class HomematicipAuth: async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" + # pylint: disable=no-self-use auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) try: await auth.init(hapid) diff --git a/homeassistant/components/homematicip_cloud/translations/fi.json b/homeassistant/components/homematicip_cloud/translations/fi.json index 9fcaacf4ba1..6a46955cddb 100644 --- a/homeassistant/components/homematicip_cloud/translations/fi.json +++ b/homeassistant/components/homematicip_cloud/translations/fi.json @@ -1,11 +1,20 @@ { "config": { "abort": { + "already_configured": "Laite on jo m\u00e4\u00e4ritetty", + "connection_aborted": "Yhdist\u00e4minen ep\u00e4onnistui", "unknown": "Tapahtui tuntematon virhe." }, "error": { "invalid_sgtin_or_pin": "Virheellinen PIN-koodi, yrit\u00e4 uudelleen.", "press_the_button": "Paina sinist\u00e4 painiketta." + }, + "step": { + "init": { + "data": { + "pin": "PIN-koodi" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/fr.json b/homeassistant/components/homematicip_cloud/translations/fr.json index 212206bb298..106ff6225d5 100644 --- a/homeassistant/components/homematicip_cloud/translations/fr.json +++ b/homeassistant/components/homematicip_cloud/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", - "connection_aborted": "Impossible de se connecter au serveur HMIP", - "unknown": "Une erreur inconnue s'est produite." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "connection_aborted": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" }, "error": { "invalid_sgtin_or_pin": "Code SGTIN ou PIN invalide, veuillez r\u00e9essayer.", @@ -16,7 +16,7 @@ "data": { "hapid": "ID du point d'acc\u00e8s (SGTIN)", "name": "Nom (facultatif, utilis\u00e9 comme pr\u00e9fixe de nom pour tous les p\u00e9riph\u00e9riques)", - "pin": "Code PIN (facultatif)" + "pin": "Code PIN" }, "title": "Choisissez le point d'acc\u00e8s HomematicIP" }, diff --git a/homeassistant/components/homematicip_cloud/translations/hu.json b/homeassistant/components/homematicip_cloud/translations/hu.json index 90fee286a3a..2915d442a37 100644 --- a/homeassistant/components/homematicip_cloud/translations/hu.json +++ b/homeassistant/components/homematicip_cloud/translations/hu.json @@ -18,7 +18,7 @@ "name": "N\u00e9v (opcion\u00e1lis, minden eszk\u00f6z n\u00e9vel\u0151tagjak\u00e9nt haszn\u00e1latos)", "pin": "PIN-k\u00f3d" }, - "title": "V\u00e1lassz HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" + "title": "V\u00e1lasszon 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)", diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 03dc9ea9c8c..c61e4fc18eb 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -43,15 +43,30 @@ async def async_setup_entry(hass, config): _LOGGER.debug("No devices found") return False - data = HoneywellData(hass, client, username, password, devices) + data = HoneywellData(hass, config, client, username, password, devices) await data.async_update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data hass.config_entries.async_setup_platforms(config, PLATFORMS) + config.async_on_unload(config.add_update_listener(update_listener)) + return True +async def update_listener(hass, config) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config.entry_id) + + +async def async_unload_entry(hass, config): + """Unload the config config and platforms.""" + unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) + if unload_ok: + hass.data.pop(DOMAIN) + return unload_ok + + def get_somecomfort_client(username, password): """Initialize the somecomfort client.""" try: @@ -70,9 +85,10 @@ def get_somecomfort_client(username, password): class HoneywellData: """Get the latest data and update.""" - def __init__(self, hass, client, username, password, devices): + def __init__(self, hass, config, client, username, password, devices): """Initialize the data object.""" self._hass = hass + self._config = config self._client = client self._username = username self._password = password @@ -102,6 +118,7 @@ class HoneywellData: return False self.devices = devices + await self._hass.config_entries.async_reload(self._config.entry_id) return True async def _refresh_devices(self): @@ -120,8 +137,9 @@ class HoneywellData: break except ( somecomfort.client.APIRateLimited, - OSError, + somecomfort.client.ConnectionError, somecomfort.client.ConnectionTimeout, + OSError, ) as exp: retries -= 1 if retries == 0: diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json index 41534be9d8d..9f6c562e888 100644 --- a/homeassistant/components/honeywell/translations/es.json +++ b/homeassistant/components/honeywell/translations/es.json @@ -1,7 +1,14 @@ { "config": { + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, "step": { "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, "description": "Por favor, introduzca las credenciales utilizadas para iniciar sesi\u00f3n en mytotalconnectcomfort.com.", "title": "Honeywell Total Connect Comfort (US)" } diff --git a/homeassistant/components/honeywell/translations/fr.json b/homeassistant/components/honeywell/translations/fr.json index b9b625eb589..fbe3def3113 100644 --- a/homeassistant/components/honeywell/translations/fr.json +++ b/homeassistant/components/honeywell/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/honeywell/translations/id.json b/homeassistant/components/honeywell/translations/id.json new file mode 100644 index 00000000000..ee1540cc787 --- /dev/null +++ b/homeassistant/components/honeywell/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index b3d5a081d1b..594d84a8068 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -2,6 +2,7 @@ from contextlib import suppress from datetime import datetime, timedelta from functools import partial +from http import HTTPStatus import json import logging import time @@ -26,13 +27,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import ( - ATTR_NAME, - HTTP_BAD_REQUEST, - HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, - URL_ROOT, -) +from homeassistant.const import ATTR_NAME, URL_ROOT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string @@ -224,11 +219,11 @@ class HTML5PushRegistrationView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) try: data = REGISTER_SCHEMA(data) except vol.Invalid as ex: - return self.json_message(humanize_error(data, ex), HTTP_BAD_REQUEST) + return self.json_message(humanize_error(data, ex), HTTPStatus.BAD_REQUEST) devname = data.get(ATTR_NAME) data.pop(ATTR_NAME, None) @@ -252,7 +247,7 @@ class HTML5PushRegistrationView(HomeAssistantView): self.registrations.pop(name) return self.json_message( - "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) def find_registration_name(self, data, suggested=None): @@ -269,7 +264,7 @@ class HTML5PushRegistrationView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) subscription = data.get(ATTR_SUBSCRIPTION) @@ -295,7 +290,7 @@ class HTML5PushRegistrationView(HomeAssistantView): except HomeAssistantError: self.registrations[found] = reg return self.json_message( - "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) return self.json_message("Push notification subscriber unregistered.") @@ -320,7 +315,9 @@ class HTML5PushCallbackView(HomeAssistantView): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, verify=False) + target_check = jwt.decode( + token, algorithms=["ES256", "HS256"], options={"verify_signature": False} + ) if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] @@ -328,7 +325,7 @@ class HTML5PushCallbackView(HomeAssistantView): return jwt.decode(token, key, algorithms=["ES256", "HS256"]) return self.json_message( - "No target found in JWT", status_code=HTTP_UNAUTHORIZED + "No target found in JWT", status_code=HTTPStatus.UNAUTHORIZED ) # The following is based on code from Auth0 @@ -339,7 +336,7 @@ class HTML5PushCallbackView(HomeAssistantView): auth = request.headers.get(AUTHORIZATION) if not auth: return self.json_message( - "Authorization header is expected", status_code=HTTP_UNAUTHORIZED + "Authorization header is expected", status_code=HTTPStatus.UNAUTHORIZED ) parts = auth.split() @@ -347,19 +344,21 @@ class HTML5PushCallbackView(HomeAssistantView): if parts[0].lower() != "bearer": return self.json_message( "Authorization header must start with Bearer", - status_code=HTTP_UNAUTHORIZED, + status_code=HTTPStatus.UNAUTHORIZED, ) if len(parts) != 2: return self.json_message( "Authorization header must be Bearer token", - status_code=HTTP_UNAUTHORIZED, + status_code=HTTPStatus.UNAUTHORIZED, ) token = parts[1] try: payload = self.decode_jwt(token) except jwt.exceptions.InvalidTokenError: - return self.json_message("token is invalid", status_code=HTTP_UNAUTHORIZED) + return self.json_message( + "token is invalid", status_code=HTTPStatus.UNAUTHORIZED + ) return payload async def post(self, request): @@ -371,7 +370,7 @@ class HTML5PushCallbackView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) event_payload = { ATTR_TAG: data.get(ATTR_TAG), @@ -557,7 +556,7 @@ def add_jwt(timestamp, target, tag, jwt_secret): ATTR_TARGET: target, ATTR_TAG: tag, } - return jwt.encode(jwt_claims, jwt_secret).decode("utf-8") + return jwt.encode(jwt_claims, jwt_secret) def create_vapid_headers(vapid_email, subscription_info, vapid_private_key): diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7004b279bd0..43ea0522594 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -45,7 +45,7 @@ def async_sign_path( secret, algorithm="HS256", ) - return f"{path}?{SIGN_QUERY_PARAM}={encoded.decode()}" + return f"{path}?{SIGN_QUERY_PARAM}={encoded}" @callback diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 2768350c183..cc661d43fd8 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,16 +1,15 @@ """Decorator for view methods to help with data validation.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from functools import wraps +from http import HTTPStatus import logging -from typing import Any, Callable +from typing import Any from aiohttp import web import voluptuous as vol -from homeassistant.const import HTTP_BAD_REQUEST - from .view import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -49,7 +48,7 @@ class RequestDataValidator: except ValueError: if not self._allow_empty or (await request.content.read()) != b"": _LOGGER.error("Invalid JSON received") - return view.json_message("Invalid JSON.", HTTP_BAD_REQUEST) + return view.json_message("Invalid JSON.", HTTPStatus.BAD_REQUEST) data = {} try: @@ -57,7 +56,7 @@ class RequestDataValidator: except vol.Invalid as err: _LOGGER.error("Data does not match schema: %s", err) return view.json_message( - f"Message format incorrect: {err}", HTTP_BAD_REQUEST + f"Message format incorrect: {err}", HTTPStatus.BAD_REQUEST ) result = await method(view, request, *args, **kwargs) diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 6dd2d9adb8a..4cc330a85ed 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from ipaddress import ip_address import logging +from types import ModuleType +from typing import Literal from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware @@ -63,23 +65,30 @@ def async_setup_forwarded( an HTTP 400 status code is thrown. """ - try: - from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - - # venv users might have already loaded it before it got upgraded so guard for this - # This can only happen when people upgrade from before 2021.8.5. - if not hasattr(remote, "is_cloud_request"): - remote = None - except ImportError: - remote = None + remote: Literal[False] | None | ModuleType = None @middleware async def forwarded_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process forwarded data by a reverse proxy.""" + nonlocal remote + + if remote is None: + # Initialize remote method + try: + from hass_nabucasa import ( # pylint: disable=import-outside-toplevel + remote, + ) + + # venv users might have an old version installed if they don't have cloud around anymore + if not hasattr(remote, "is_cloud_request"): + remote = False + except ImportError: + remote = False + # Skip requests from Remote UI - if remote is not None and remote.is_cloud_request.get(): + if remote and remote.is_cloud_request.get(): # type: ignore return await handler(request) # Handle X-Forwarded-For diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 129c43600c4..39225c918e5 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from http import HTTPStatus import json import logging from typing import Any @@ -48,7 +49,7 @@ class HomeAssistantView: @staticmethod def json( result: Any, - status_code: int = HTTP_OK, + status_code: HTTPStatus | int = HTTPStatus.OK, headers: LooseHeaders | None = None, ) -> web.Response: """Return a JSON response.""" @@ -60,7 +61,7 @@ class HomeAssistantView: response = web.Response( body=msg, content_type=CONTENT_TYPE_JSON, - status=status_code, + status=int(status_code), headers=headers, ) response.enable_compression() @@ -69,7 +70,7 @@ class HomeAssistantView: def json_message( self, message: str, - status_code: int = HTTP_OK, + status_code: HTTPStatus | int = HTTPStatus.OK, message_code: str | None = None, headers: LooseHeaders | None = None, ) -> web.Response: diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ec9281659f5..92122f1b2be 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from contextlib import suppress from datetime import timedelta import logging import time -from typing import Any, Callable, cast +from typing import Any, cast import attr from huawei_lte_api.AuthorizedConnection import AuthorizedConnection diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 47987e5607e..568f7c31a53 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from bisect import bisect +from collections.abc import Callable import logging import re -from typing import Callable, NamedTuple +from typing import NamedTuple import attr @@ -12,6 +13,8 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_SIGNAL_STRENGTH, DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -53,6 +56,7 @@ class SensorMeta(NamedTuple): device_class: str | None = None icon: str | Callable[[StateType], str] | None = None unit: str | None = None + state_class: str | None = None enabled_default: bool = False include: re.Pattern[str] | None = None exclude: re.Pattern[str] | None = None @@ -122,6 +126,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-11, -8, -5), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rsrp"): SensorMeta( @@ -134,6 +139,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-110, -95, -80), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rssi"): SensorMeta( @@ -146,6 +152,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-80, -70, -60), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "sinr"): SensorMeta( @@ -158,6 +165,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((0, 5, 10), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rscp"): SensorMeta( @@ -170,6 +178,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-95, -85, -75), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_DEVICE_SIGNAL, "ecio"): SensorMeta( name="EC/IO", @@ -181,6 +190,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-2", "mdi:signal-cellular-3", )[bisect((-20, -10, -6), x if x is not None else -1000)], + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_DEVICE_SIGNAL, "transmode"): SensorMeta(name="Transmission mode"), (KEY_DEVICE_SIGNAL, "cqi0"): SensorMeta( @@ -193,11 +203,17 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { ), (KEY_DEVICE_SIGNAL, "ltedlfreq"): SensorMeta( name="Downlink frequency", - formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), + formatter=lambda x: ( + round(int(x) / 10) if x is not None else None, + FREQUENCY_MEGAHERTZ, + ), ), (KEY_DEVICE_SIGNAL, "lteulfreq"): SensorMeta( name="Uplink frequency", - formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), + formatter=lambda x: ( + round(int(x) / 10) if x is not None else None, + FREQUENCY_MEGAHERTZ, + ), ), KEY_MONITORING_CHECK_NOTIFICATIONS: SensorMeta( exclude=re.compile( @@ -212,10 +228,16 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { exclude=re.compile(r"^month(duration|lastcleartime)$", re.IGNORECASE) ), (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthDownload"): SensorMeta( - name="Current month download", unit=DATA_BYTES, icon="mdi:download" + name="Current month download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthUpload"): SensorMeta( - name="Current month upload", unit=DATA_BYTES, icon="mdi:upload" + name="Current month upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, ), KEY_MONITORING_STATUS: SensorMeta( include=re.compile( @@ -250,29 +272,43 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer-outline" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): SensorMeta( - name="Current connection download", unit=DATA_BYTES, icon="mdi:download" + name="Current connection download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownloadRate"): SensorMeta( name="Current download rate", unit=DATA_RATE_BYTES_PER_SECOND, icon="mdi:download", + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUpload"): SensorMeta( - name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload" + name="Current connection upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUploadRate"): SensorMeta( name="Current upload rate", unit=DATA_RATE_BYTES_PER_SECOND, icon="mdi:upload", + state_class=STATE_CLASS_MEASUREMENT, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): SensorMeta( name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer-outline" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): SensorMeta( - name="Total download", unit=DATA_BYTES, icon="mdi:download" + name="Total download", + unit=DATA_BYTES, + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): SensorMeta( - name="Total upload", unit=DATA_BYTES, icon="mdi:upload" + name="Total upload", + unit=DATA_BYTES, + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, ), KEY_NET_CURRENT_PLMN: SensorMeta( exclude=re.compile(r"^(Rat|ShortName|Spn)$", re.IGNORECASE) @@ -448,6 +484,11 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): return icon(self.state) return icon + @property + def state_class(self) -> str | None: + """Return sensor state class.""" + return self.meta.state_class + @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json index da8fbcbd115..d04f7e83d3f 100644 --- a/homeassistant/components/huawei_lte/translations/fr.json +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Ce p\u00e9riph\u00e9rique est d\u00e9j\u00e0 en cours de configuration", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_huawei_lte": "Pas un appareil Huawei LTE" }, "error": { diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index 22bd37c37ba..91f70a17e46 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z" }, "error": { - "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9se", + "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9s", "incorrect_password": "Hib\u00e1s jelsz\u00f3", "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", diff --git a/homeassistant/components/huawei_lte/translations/id.json b/homeassistant/components/huawei_lte/translations/id.json index 2077b31ccd7..de784fd3e94 100644 --- a/homeassistant/components/huawei_lte/translations/id.json +++ b/homeassistant/components/huawei_lte/translations/id.json @@ -15,7 +15,7 @@ "response_error": "Kesalahan tidak dikenal dari perangkat", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Huawei LTE: {name}", + "flow_title": "{name}", "step": { "user": { "data": { @@ -23,7 +23,7 @@ "url": "URL", "username": "Nama Pengguna" }, - "description": "Masukkan detail akses perangkat. Menentukan nama pengguna dan kata sandi bersifat opsional, tetapi memungkinkan dukungan untuk fitur integrasi lainnya. Selain itu, penggunaan koneksi resmi dapat menyebabkan masalah mengakses antarmuka web perangkat dari luar Home Assistant saat integrasi aktif, dan sebaliknya.", + "description": "Masukkan detail akses perangkat.", "title": "Konfigurasikan Huawei LTE" } } diff --git a/homeassistant/components/huawei_lte/translations/zh-Hans.json b/homeassistant/components/huawei_lte/translations/zh-Hans.json index 4fb447403d6..a63ff964b62 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hans.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hans.json @@ -1,8 +1,42 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c", + "not_huawei_lte": "\u8be5\u8bbe\u5907\u4e0d\u662f\u534e\u4e3a LTE \u8bbe\u5907" + }, "error": { + "connection_timeout": "\u8fde\u63a5\u8d85\u65f6", + "incorrect_password": "\u5bc6\u7801\u9519\u8bef", "incorrect_username": "\u7528\u6237\u540d\u9519\u8bef", - "login_attempts_exceeded": "\u5df2\u8d85\u8fc7\u6700\u5927\u767b\u5f55\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "invalid_url": "\u65e0\u6548\u7f51\u5740", + "login_attempts_exceeded": "\u5df2\u8d85\u8fc7\u6700\u5927\u767b\u5f55\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5", + "response_error": "\u8bbe\u5907\u51fa\u73b0\u672a\u77e5\u9519\u8bef", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "url": "\u4e3b\u673a\u5730\u5740", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u8bbe\u5907\u76f8\u5173\u4fe1\u606f\u4ee5\u4fbf\u8fde\u63a5\u81f3\u8be5\u8bbe\u5907", + "title": "\u914d\u7f6e\u534e\u4e3aLTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u63a8\u9001\u670d\u52a1\u540d\u79f0\uff08\u66f4\u6539\u540e\u9700\u8981\u91cd\u8f7d\uff09", + "recipient": "\u77ed\u4fe1\u901a\u77e5\u6536\u4ef6\u4eba", + "track_new_devices": "\u8ddf\u8e2a\u65b0\u8bbe\u5907", + "track_wired_clients": "\u8ddf\u8e2a\u6709\u7ebf\u7f51\u7edc\u5ba2\u6237\u7aef", + "unauthenticated_mode": "\u672a\u7ecf\u8eab\u4efd\u9a8c\u8bc1\u7684\u6a21\u5f0f\uff08\u66f4\u6539\u540e\u9700\u8981\u91cd\u8f7d\uff09" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 4a7ebd01fbd..72938ebfe0a 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -207,6 +207,24 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.bridge = bridge return await self.async_step_link() + async def async_step_zeroconf(self, discovery_info): + """Handle a discovered Hue bridge. + + This flow is triggered by the Zeroconf component. It will check if the + host is already configured and delegate to the import step if not. + """ + bridge = self._async_get_bridge( + discovery_info["host"], discovery_info["properties"]["bridgeid"] + ) + + await self.async_set_unique_id(bridge.id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: bridge.host}, reload_on_update=False + ) + + self.bridge = bridge + return await self.async_step_link() + async def async_step_homekit(self, discovery_info): """Handle a discovered Hue bridge on HomeKit. diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 77561e47dc5..5af68b9d769 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -119,7 +119,9 @@ async def async_validate_trigger_config(hass, config): trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) if not device: - raise InvalidDeviceAutomationConfig("Device {config[CONF_DEVICE_ID]} not found") + raise InvalidDeviceAutomationConfig( + f"Device {config[CONF_DEVICE_ID]} not found" + ) if device.model not in REMOTES: raise InvalidDeviceAutomationConfig( @@ -127,7 +129,9 @@ async def async_validate_trigger_config(hass, config): ) if trigger not in REMOTES[device.model]: - raise InvalidDeviceAutomationConfig("Device does not support trigger {trigger}") + raise InvalidDeviceAutomationConfig( + f"Device does not support trigger {trigger}" + ) return config diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index ea89d91113b..cc3144b99ca 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -282,6 +282,7 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = False self.is_philips = False self.is_innr = False + self.is_ewelink = False self.is_livarno = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None @@ -289,6 +290,7 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" self.is_innr = light.manufacturername == "innr" + self.is_ewelink = light.manufacturername == "eWeLink" self.is_livarno = light.manufacturername.startswith("_TZ3000_") self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut @@ -497,7 +499,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr and not self.is_livarno: + elif not self.is_innr and not self.is_ewelink and not self.is_livarno: command["alert"] = "none" if ATTR_EFFECT in kwargs: diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 32b3cd4ee51..6640ffc9fae 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.6.1"], + "requirements": ["aiohue==2.6.3"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -21,6 +21,7 @@ "homekit": { "models": ["BSB002"] }, + "zeroconf": ["_hue._tcp.local."], "codeowners": ["@balloob", "@frenck"], "quality_scale": "platinum", "iot_class": "local_push" diff --git a/homeassistant/components/hue/translations/fr.json b/homeassistant/components/hue/translations/fr.json index e9dd546840f..ee82b3ec4e6 100644 --- a/homeassistant/components/hue/translations/fr.json +++ b/homeassistant/components/hue/translations/fr.json @@ -2,13 +2,13 @@ "config": { "abort": { "all_configured": "Tous les ponts Philips Hue sont d\u00e9j\u00e0 configur\u00e9s", - "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", - "cannot_connect": "Connexion au pont impossible", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", "discover_timeout": "D\u00e9tection de ponts Philips Hue impossible", "no_bridges": "Aucun pont Philips Hue n'a \u00e9t\u00e9 d\u00e9couvert", "not_hue_bridge": "Pas de pont Hue", - "unknown": "Une erreur inconnue s'est produite" + "unknown": "Erreur inattendue" }, "error": { "linking": "Erreur inattendue", diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index 30084ee9940..a114fc2c890 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -3,10 +3,10 @@ "abort": { "all_configured": "M\u00e1r minden Philips Hue bridge konfigur\u00e1lt", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "discover_timeout": "Nem tal\u00e1ltam a Hue bridget", - "no_bridges": "Nem tal\u00e1ltam Philips Hue bridget", + "discover_timeout": "Nem tal\u00e1lhat\u00f3 a Hue bridge", + "no_bridges": "Nem tal\u00e1lhat\u00f3 Philips Hue bridget", "not_hue_bridge": "Nem egy Hue Bridge", "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt" }, @@ -17,17 +17,17 @@ "step": { "init": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "title": "V\u00e1lassz Hue bridge-t" + "title": "V\u00e1lasszon Hue bridge-t" }, "link": { - "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistant-ben val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)", + "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistantban val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n![Gomb helye](/static/images/config_philips_hue.jpg)", "title": "Kapcsol\u00f3d\u00e1s a hubhoz" }, "manual": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "title": "A Hue bridge manu\u00e1lis konfigur\u00e1l\u00e1sa" } diff --git a/homeassistant/components/huisbaasje/config_flow.py b/homeassistant/components/huisbaasje/config_flow.py index 4139b0d75c5..fc686f25809 100644 --- a/homeassistant/components/huisbaasje/config_flow.py +++ b/homeassistant/components/huisbaasje/config_flow.py @@ -69,6 +69,7 @@ class HuisbaasjeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Data has the keys from DATA_SCHEMA with values provided by the user. """ + # pylint: disable=no-self-use username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] diff --git a/homeassistant/components/huisbaasje/translations/fr.json b/homeassistant/components/huisbaasje/translations/fr.json index 9012293fa48..aa84ec33d8c 100644 --- a/homeassistant/components/huisbaasje/translations/fr.json +++ b/homeassistant/components/huisbaasje/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "[%key::common::config_flow::error::cannot_connect%]", - "invalid_auth": "Authentification invalide ", - "unknown": "Erreur inatendue" + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" }, "step": { "user": { diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 5c761e798ea..a049af9afec 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import ( DEVICE_TRIGGER_BASE_SCHEMA, toggle_entity, @@ -80,7 +83,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/humidifier/translations/fr.json b/homeassistant/components/humidifier/translations/fr.json index 236c3b93343..746d9930426 100644 --- a/homeassistant/components/humidifier/translations/fr.json +++ b/homeassistant/components/humidifier/translations/fr.json @@ -20,8 +20,8 @@ }, "state": { "_": { - "off": "Eteint", - "on": "Allum\u00e9" + "off": "Inactif", + "on": "Actif" } }, "title": "Humidificateur" diff --git a/homeassistant/components/hunterdouglas_powerview/translations/fr.json b/homeassistant/components/hunterdouglas_powerview/translations/fr.json index 68ea30b293f..9eb8edda7db 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/fr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "flow_title": "{name} ({host})", diff --git a/homeassistant/components/hunterdouglas_powerview/translations/hu.json b/homeassistant/components/hunterdouglas_powerview/translations/hu.json index 1fedd8bc126..e6afd8a1dc4 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/hu.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/hu.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "Csatlakozzon a PowerView Hubhoz" }, "user": { diff --git a/homeassistant/components/hvv_departures/translations/fr.json b/homeassistant/components/hvv_departures/translations/fr.json index 1ade2ebd742..0c7fd03f148 100644 --- a/homeassistant/components/hvv_departures/translations/fr.json +++ b/homeassistant/components/hvv_departures/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "no_results": "Aucun r\u00e9sultat. Essayez avec une autre station / adresse" }, "step": { diff --git a/homeassistant/components/hvv_departures/translations/hu.json b/homeassistant/components/hvv_departures/translations/hu.json index dfbdd92f27a..41113527ecb 100644 --- a/homeassistant/components/hvv_departures/translations/hu.json +++ b/homeassistant/components/hvv_departures/translations/hu.json @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 1f1b2c03157..56ebdc0d88c 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -6,22 +6,11 @@ from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOISTURE, -) -from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP -from homeassistant.components.switch import DEVICE_CLASS_SWITCH -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_ACCESS_TOKEN, - CONF_SCAN_INTERVAL, - TIME_MINUTES, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) @@ -39,27 +28,6 @@ DATA_HYDRAWISE = "hydrawise" DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 -DEVICE_MAP_INDEX = [ - "KEY_INDEX", - "ICON_INDEX", - "DEVICE_CLASS_INDEX", - "UNIT_OF_MEASURE_INDEX", -] -DEVICE_MAP = { - "auto_watering": ["Automatic Watering", None, DEVICE_CLASS_SWITCH, None], - "is_watering": ["Watering", None, DEVICE_CLASS_MOISTURE, None], - "manual_watering": ["Manual Watering", None, DEVICE_CLASS_SWITCH, None], - "next_cycle": ["Next Cycle", None, DEVICE_CLASS_TIMESTAMP, None], - "status": ["Status", None, DEVICE_CLASS_CONNECTIVITY, None], - "watering_time": ["Watering Time", "mdi:water-pump", None, TIME_MINUTES], -} - -BINARY_SENSORS = ["is_watering", "status"] - -SENSORS = ["next_cycle", "watering_time"] - -SWITCHES = ["auto_watering", "manual_watering"] - SCAN_INTERVAL = timedelta(seconds=30) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" @@ -118,17 +86,11 @@ class HydrawiseHub: class HydrawiseEntity(Entity): """Entity class for Hydrawise devices.""" - def __init__(self, data, sensor_type): + def __init__(self, data, description: EntityDescription): """Initialize the Hydrawise entity.""" + self.entity_description = description self.data = data - self._sensor_type = sensor_type - self._name = f"{self.data['name']} {DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index('KEY_INDEX')]}" - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"{self.data['name']} {description.name}" async def async_added_to_hass(self): """Register callbacks.""" @@ -147,15 +109,3 @@ class HydrawiseEntity(Entity): def extra_state_attributes(self): """Return the state attributes.""" return {ATTR_ATTRIBUTION: ATTRIBUTION, "identifier": self.data.get("relay")} - - @property - def device_class(self): - """Return the device class of the sensor type.""" - return DEVICE_MAP[self._sensor_type][ - DEVICE_MAP_INDEX.index("DEVICE_CLASS_INDEX") - ] - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index("ICON_INDEX")] diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index e39ffce73a9..7a673a1e7ae 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,20 +1,46 @@ """Support for Hydrawise sprinkler binary sensors.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOISTURE, + PLATFORM_SCHEMA, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from . import BINARY_SENSORS, DATA_HYDRAWISE, HydrawiseEntity +from . import DATA_HYDRAWISE, HydrawiseEntity _LOGGER = logging.getLogger(__name__) +BINARY_SENSOR_STATUS = BinarySensorEntityDescription( + key="status", + name="Status", + device_class=DEVICE_CLASS_CONNECTIVITY, +) + +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_watering", + name="Watering", + device_class=DEVICE_CLASS_MOISTURE, + ), +) + +BINARY_SENSOR_KEYS: list[str] = [ + desc.key for desc in (BINARY_SENSOR_STATUS, *BINARY_SENSOR_TYPES) +] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSORS): vol.All( - cv.ensure_list, [vol.In(BINARY_SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(BINARY_SENSOR_KEYS)] ) } ) @@ -23,35 +49,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a sensor for a Hydrawise device.""" hydrawise = hass.data[DATA_HYDRAWISE].data + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - if sensor_type == "status": - sensors.append( - HydrawiseBinarySensor(hydrawise.current_controller, sensor_type) - ) - else: - # create a sensor for each zone - for zone in hydrawise.relays: - sensors.append(HydrawiseBinarySensor(zone, sensor_type)) + entities = [] + if BINARY_SENSOR_STATUS.key in monitored_conditions: + entities.append( + HydrawiseBinarySensor(hydrawise.current_controller, BINARY_SENSOR_STATUS) + ) - add_entities(sensors, True) + # create a sensor for each zone + entities.extend( + [ + HydrawiseBinarySensor(zone, description) + for zone in hydrawise.relays + for description in BINARY_SENSOR_TYPES + if description.key in monitored_conditions + ] + ) + + add_entities(entities, True) class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - def update(self): """Get the latest data and updates the state.""" - _LOGGER.debug("Updating Hydrawise binary sensor: %s", self._name) + _LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) mydata = self.hass.data[DATA_HYDRAWISE].data - if self._sensor_type == "status": - self._state = mydata.status == "All good!" - elif self._sensor_type == "is_watering": + if self.entity_description.key == "status": + self._attr_is_on = mydata.status == "All good!" + elif self.entity_description.key == "is_watering": relay_data = mydata.relays[self.data["relay"] - 1] - self._state = relay_data["timestr"] == "Now" + self._attr_is_on = relay_data["timestr"] == "Now" diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 0e9afb6d729..f8c02309569 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,21 +1,47 @@ """Support for Hydrawise sprinkler sensors.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_TIMESTAMP, + TIME_MINUTES, +) import homeassistant.helpers.config_validation as cv from homeassistant.util import dt -from . import DATA_HYDRAWISE, DEVICE_MAP, DEVICE_MAP_INDEX, SENSORS, HydrawiseEntity +from . import DATA_HYDRAWISE, HydrawiseEntity _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="next_cycle", + name="Next Cycle", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key="watering_time", + name="Watering Time", + icon="mdi:water-pump", + native_unit_of_measurement=TIME_MINUTES, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -27,43 +53,34 @@ WATERING_TIME_ICON = "mdi:water-pump" def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a sensor for a Hydrawise device.""" hydrawise = hass.data[DATA_HYDRAWISE].data + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - for zone in hydrawise.relays: - sensors.append(HydrawiseSensor(zone, sensor_type)) + entities = [ + HydrawiseSensor(zone, description) + for zone in hydrawise.relays + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(sensors, True) + add_entities(entities, True) class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the units of measurement.""" - return DEVICE_MAP[self._sensor_type][ - DEVICE_MAP_INDEX.index("UNIT_OF_MEASURE_INDEX") - ] - def update(self): """Get the latest data and updates the states.""" mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise sensor: %s", self._name) + _LOGGER.debug("Updating Hydrawise sensor: %s", self.name) relay_data = mydata.relays[self.data["relay"] - 1] - if self._sensor_type == "watering_time": + if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": - self._state = int(relay_data["run"] / 60) + self._attr_native_value = int(relay_data["run"] / 60) else: - self._state = 0 + self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) _LOGGER.debug("New cycle time: %s", next_cycle) - self._state = dt.utc_from_timestamp( + self._attr_native_value = dt.utc_from_timestamp( dt.as_timestamp(dt.now()) + next_cycle ).isoformat() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index a385e504d7f..8b3707ad5a0 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,9 +1,16 @@ """Support for Hydrawise cloud switches.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.components.switch import ( + DEVICE_CLASS_SWITCH, + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv @@ -12,16 +19,30 @@ from . import ( CONF_WATERING_TIME, DATA_HYDRAWISE, DEFAULT_WATERING_TIME, - SWITCHES, HydrawiseEntity, ) _LOGGER = logging.getLogger(__name__) +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="auto_watering", + name="Automatic Watering", + device_class=DEVICE_CLASS_SWITCH, + ), + SwitchEntityDescription( + key="manual_watering", + name="Manual Watering", + device_class=DEVICE_CLASS_SWITCH, + ), +) + +SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCHES): vol.All( - cv.ensure_list, [vol.In(SWITCHES)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( + cv.ensure_list, [vol.In(SWITCH_KEYS)] ), vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): vol.All( vol.In(ALLOWED_WATERING_TIME) @@ -33,57 +54,55 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a sensor for a Hydrawise device.""" hydrawise = hass.data[DATA_HYDRAWISE].data + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + default_watering_timer = config[CONF_WATERING_TIME] - default_watering_timer = config.get(CONF_WATERING_TIME) + entities = [ + HydrawiseSwitch(zone, description, default_watering_timer) + for zone in hydrawise.relays + for description in SWITCH_TYPES + if description.key in monitored_conditions + ] - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - # Create a switch for each zone - for zone in hydrawise.relays: - sensors.append(HydrawiseSwitch(default_watering_timer, zone, sensor_type)) - - add_entities(sensors, True) + add_entities(entities, True) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" - def __init__(self, default_watering_timer, *args): + def __init__( + self, data, description: SwitchEntityDescription, default_watering_timer + ): """Initialize a switch for Hydrawise device.""" - super().__init__(*args) + super().__init__(data, description) self._default_watering_timer = default_watering_timer - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs): """Turn the device on.""" relay_data = self.data["relay"] - 1 - if self._sensor_type == "manual_watering": + if self.entity_description.key == "manual_watering": self.hass.data[DATA_HYDRAWISE].data.run_zone( self._default_watering_timer, relay_data ) - elif self._sensor_type == "auto_watering": + elif self.entity_description.key == "auto_watering": self.hass.data[DATA_HYDRAWISE].data.suspend_zone(0, relay_data) def turn_off(self, **kwargs): """Turn the device off.""" relay_data = self.data["relay"] - 1 - if self._sensor_type == "manual_watering": + if self.entity_description.key == "manual_watering": self.hass.data[DATA_HYDRAWISE].data.run_zone(0, relay_data) - elif self._sensor_type == "auto_watering": + elif self.entity_description.key == "auto_watering": self.hass.data[DATA_HYDRAWISE].data.suspend_zone(365, relay_data) def update(self): """Update device state.""" relay_data = self.data["relay"] - 1 mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise switch: %s", self._name) - if self._sensor_type == "manual_watering": - self._state = mydata.relays[relay_data]["timestr"] == "Now" - elif self._sensor_type == "auto_watering": - self._state = (mydata.relays[relay_data]["timestr"] != "") and ( + _LOGGER.debug("Updating Hydrawise switch: %s", self.name) + if self.entity_description.key == "manual_watering": + self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now" + elif self.entity_description.key == "auto_watering": + self._attr_is_on = (mydata.relays[relay_data]["timestr"] != "") and ( mydata.relays[relay_data]["timestr"] != "Now" ) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 36185c68758..b43b25ca5ac 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress import logging -from typing import Any, Callable, cast +from typing import Any, cast from awesomeversion import AwesomeVersion from hyperion import client, const as hyperion_const diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index e9d23b4077e..d27e96e85de 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -1,11 +1,11 @@ """Support for Hyperion-NG remotes.""" from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence import functools import logging from types import MappingProxyType -from typing import Any, Callable +from typing import Any from hyperion import client, const diff --git a/homeassistant/components/hyperion/translations/fr.json b/homeassistant/components/hyperion/translations/fr.json index 57870c3b3ef..7bb5c02543d 100644 --- a/homeassistant/components/hyperion/translations/fr.json +++ b/homeassistant/components/hyperion/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Le service est d\u00e9ja configur\u00e9 ", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "auth_new_token_not_granted_error": "Le jeton nouvellement cr\u00e9\u00e9 n'a pas \u00e9t\u00e9 approuv\u00e9 sur l'interface utilisateur Hyperion", "auth_new_token_not_work_error": "\u00c9chec de l'authentification \u00e0 l'aide du jeton nouvellement cr\u00e9\u00e9", "auth_required_error": "Impossible de d\u00e9terminer si une autorisation est requise", - "cannot_connect": "Echec de connection", + "cannot_connect": "\u00c9chec de connexion", "no_id": "L'instance Hyperion Ambilight n'a pas signal\u00e9 son identifiant", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Echec de la connexion ", - "invalid_access_token": "jeton d'acc\u00e8s Invalide" + "cannot_connect": "\u00c9chec de connexion", + "invalid_access_token": "Jeton d'acc\u00e8s non valide" }, "step": { "auth": { diff --git a/homeassistant/components/hyperion/translations/he.json b/homeassistant/components/hyperion/translations/he.json index dd22953025f..a48e41ec0d2 100644 --- a/homeassistant/components/hyperion/translations/he.json +++ b/homeassistant/components/hyperion/translations/he.json @@ -12,7 +12,7 @@ }, "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Hyperion Ambilight \u05d4\u05d6\u05d4 \u05dc-Home Assistant?\n\n**\u05de\u05d0\u05e8\u05d7:** {host}\n**\u05e4\u05ea\u05d7\u05d4:** {port}\n**\u05de\u05d6\u05d4\u05d4**: {id}" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Hyperion Ambilight \u05d4\u05d6\u05d4 \u05dc-Home Assistant?\n\n**\u05de\u05d0\u05e8\u05d7:** {host}\n**\u05e4\u05ea\u05d7\u05d4:** {port}\n**\u05de\u05d6\u05d4\u05d4**: {id}" }, "user": { "data": { diff --git a/homeassistant/components/hyperion/translations/hu.json b/homeassistant/components/hyperion/translations/hu.json index 852c108c0e9..3fa440c41d5 100644 --- a/homeassistant/components/hyperion/translations/hu.json +++ b/homeassistant/components/hyperion/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s 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", @@ -23,11 +23,11 @@ "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}", + "description": "Hozz\u00e1 szeretn\u00e9 adni ezt a Hyperion Ambilight-ot az Otthoni asszisztenshez? \n\n ** C\u00edm: ** {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} \"", + "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": { @@ -35,7 +35,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } } diff --git a/homeassistant/components/ialarm/translations/fr.json b/homeassistant/components/ialarm/translations/fr.json index ae61afa9d78..03844ccf99b 100644 --- a/homeassistant/components/ialarm/translations/fr.json +++ b/homeassistant/components/ialarm/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de la connexion", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/ialarm/translations/hu.json b/homeassistant/components/ialarm/translations/hu.json index e69c6e7e7ea..a98836bb7b7 100644 --- a/homeassistant/components/ialarm/translations/hu.json +++ b/homeassistant/components/ialarm/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "port": "Port" } } diff --git a/homeassistant/components/iaqualink/translations/hu.json b/homeassistant/components/iaqualink/translations/hu.json index 1ca85c41190..2b0b9ac3e67 100644 --- a/homeassistant/components/iaqualink/translations/hu.json +++ b/homeassistant/components/iaqualink/translations/hu.json @@ -12,7 +12,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "K\u00e9rj\u00fck, adja meg iAqualink-fi\u00f3kja felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t.", + "description": "K\u00e9rj\u00fck, adja meg iAqualink-fi\u00f3kj\u00e1nak felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t.", "title": "Csatlakoz\u00e1s az iAqualinkhez" } } diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 2f53e782750..233df6a7556 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -134,13 +134,13 @@ class IcloudTrackerEntity(TrackerEntity): def icon_for_icloud_device(icloud_device: IcloudDevice) -> str: - """Return a battery icon valid identifier.""" + """Return an icon for the device.""" switcher = { - "iPad": "mdi:tablet-ipad", - "iPhone": "mdi:cellphone-iphone", + "iPad": "mdi:tablet", + "iPhone": "mdi:cellphone", "iPod": "mdi:ipod", "iMac": "mdi:desktop-mac", - "MacBookPro": "mdi:laptop-mac", + "MacBookPro": "mdi:laptop", } return switcher.get(icloud_device.device_class, "mdi:cellphone-link") diff --git a/homeassistant/components/icloud/translations/ca.json b/homeassistant/components/icloud/translations/ca.json index 6e92897161a..0ffdf5bc0c1 100644 --- a/homeassistant/components/icloud/translations/ca.json +++ b/homeassistant/components/icloud/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_device": "Cap dels teus dispositius t\u00e9 activada la opci\u00f3 \"Troba el meu iPhone\"", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, diff --git a/homeassistant/components/icloud/translations/fr.json b/homeassistant/components/icloud/translations/fr.json index 77fc925851e..f9c0ceb3db9 100644 --- a/homeassistant/components/icloud/translations/fr.json +++ b/homeassistant/components/icloud/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "no_device": "Aucun de vos appareils n'a activ\u00e9 \"Find my iPhone\"", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, diff --git a/homeassistant/components/icloud/translations/hu.json b/homeassistant/components/icloud/translations/hu.json index 722b3711e67..e858eedb757 100644 --- a/homeassistant/components/icloud/translations/hu.json +++ b/homeassistant/components/icloud/translations/hu.json @@ -8,7 +8,7 @@ "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "send_verification_code": "Nem siker\u00fclt elk\u00fcldeni az ellen\u0151rz\u0151 k\u00f3dot", - "validate_verification_code": "Nem siker\u00fclt ellen\u0151rizni az ellen\u0151rz\u0151 k\u00f3dot, ki kell v\u00e1lasztania egy megb\u00edzhat\u00f3s\u00e1gi eszk\u00f6zt, \u00e9s \u00fajra kell ind\u00edtania az ellen\u0151rz\u00e9st" + "validate_verification_code": "Nem siker\u00fclt hiteles\u00edteni az ellen\u0151rz\u0151 k\u00f3dot, k\u00e9rem, pr\u00f3b\u00e1lja meg \u00fajra" }, "step": { "reauth": { diff --git a/homeassistant/components/ifttt/translations/hu.json b/homeassistant/components/ifttt/translations/hu.json index 9898beb3e92..2f64056e985 100644 --- a/homeassistant/components/ifttt/translations/hu.json +++ b/homeassistant/components/ifttt/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, akkor az [IFTTT Webhook applet]({applet_url}) \"Make a web request\" m\u0171velet\u00e9t kell haszn\u00e1lnia. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatiz\u00e1l\u00e1sokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, akkor az [IFTTT Webhook applet]({applet_url}) \"Make a web request\" m\u0171velet\u00e9t kell haszn\u00e1lnia. \n\nT\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\nL\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatizmusokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az IFTTT-t?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani az IFTTT-t?", "title": "IFTTT Webhook Applet be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 9fb99321ff2..0ce2372867a 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -1,9 +1,14 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, @@ -18,11 +23,36 @@ INCOMFORT_HEATER_TEMP = "CV Temp" INCOMFORT_PRESSURE = "CV Pressure" INCOMFORT_TAP_TEMP = "Tap Temp" -INCOMFORT_MAP_ATTRS = { - INCOMFORT_HEATER_TEMP: ["heater_temp", "is_pumping"], - INCOMFORT_PRESSURE: ["pressure", None], - INCOMFORT_TAP_TEMP: ["tap_temp", "is_tapping"], -} + +@dataclass +class IncomfortSensorEntityDescription(SensorEntityDescription): + """Describes Incomfort sensor entity.""" + + extra_key: str | None = None + + +SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( + IncomfortSensorEntityDescription( + key="pressure", + name=INCOMFORT_PRESSURE, + device_class=DEVICE_CLASS_PRESSURE, + native_unit_of_measurement=PRESSURE_BAR, + ), + IncomfortSensorEntityDescription( + key="heater_temp", + name=INCOMFORT_HEATER_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + extra_key="is_pumping", + ), + IncomfortSensorEntityDescription( + key="tap_temp", + name=INCOMFORT_TAP_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + extra_key="is_tapping", + ), +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -33,70 +63,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= client = hass.data[DOMAIN]["client"] heaters = hass.data[DOMAIN]["heaters"] - async_add_entities( - [IncomfortPressure(client, h, INCOMFORT_PRESSURE) for h in heaters] - + [IncomfortTemperature(client, h, INCOMFORT_HEATER_TEMP) for h in heaters] - + [IncomfortTemperature(client, h, INCOMFORT_TAP_TEMP) for h in heaters] - ) + entities = [ + IncomfortSensor(client, heater, description) + for heater in heaters + for description in SENSOR_TYPES + ] + + async_add_entities(entities) class IncomfortSensor(IncomfortChild, SensorEntity): """Representation of an InComfort/InTouch sensor device.""" - def __init__(self, client, heater, name) -> None: + entity_description: IncomfortSensorEntityDescription + + def __init__( + self, client, heater, description: IncomfortSensorEntityDescription + ) -> None: """Initialize the sensor.""" super().__init__() + self.entity_description = description self._client = client self._heater = heater - self._unique_id = f"{heater.serial_no}_{slugify(name)}" - self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(name)}" - self._name = f"Boiler {name}" - - self._device_class = None - self._state_attr = INCOMFORT_MAP_ATTRS[name][0] - self._unit_of_measurement = None + self._unique_id = f"{heater.serial_no}_{slugify(description.name)}" + self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(description.name)}" + self._name = f"Boiler {description.name}" @property def native_value(self) -> str | None: """Return the state of the sensor.""" - return self._heater.status[self._state_attr] - - @property - def device_class(self) -> str | None: - """Return the device class of the sensor.""" - return self._device_class - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - -class IncomfortPressure(IncomfortSensor): - """Representation of an InTouch CV Pressure sensor.""" - - def __init__(self, client, heater, name) -> None: - """Initialize the sensor.""" - super().__init__(client, heater, name) - - self._device_class = DEVICE_CLASS_PRESSURE - self._unit_of_measurement = PRESSURE_BAR - - -class IncomfortTemperature(IncomfortSensor): - """Representation of an InTouch Temperature sensor.""" - - def __init__(self, client, heater, name) -> None: - """Initialize the signal strength sensor.""" - super().__init__(client, heater, name) - - self._attr = INCOMFORT_MAP_ATTRS[name][1] - self._device_class = DEVICE_CLASS_TEMPERATURE - self._unit_of_measurement = TEMP_CELSIUS + return self._heater.status[self.entity_description.key] @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes.""" - return {self._attr: self._heater.status[self._attr]} + if (extra_key := self.entity_description.extra_key) is None: + return None + return {extra_key: self._heater.status[extra_key]} diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index bb5cf0173c1..407036e327c 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -1,6 +1,7 @@ """Support for sending data to an Influx database.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging @@ -8,7 +9,7 @@ import math import queue import threading import time -from typing import Any, Callable +from typing import Any from influxdb import InfluxDBClient, exceptions from influxdb_client import InfluxDBClient as InfluxDBClientV2 diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 9c0cc202cfa..89a487d630f 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -66,7 +66,7 @@ CREATE_FIELDS = { vol.Required(CONF_MIN): vol.Coerce(float), vol.Required(CONF_MAX): vol.Coerce(float), vol.Optional(CONF_INITIAL): vol.Coerce(float), - vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float), vol.Range(min=1e-3)), + vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float), vol.Range(min=1e-9)), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_MODE, default=MODE_SLIDER): vol.In([MODE_BOX, MODE_SLIDER]), @@ -77,7 +77,7 @@ UPDATE_FIELDS = { vol.Optional(CONF_MIN): vol.Coerce(float), vol.Optional(CONF_MAX): vol.Coerce(float), vol.Optional(CONF_INITIAL): vol.Coerce(float), - vol.Optional(CONF_STEP): vol.All(vol.Coerce(float), vol.Range(min=1e-3)), + vol.Optional(CONF_STEP): vol.All(vol.Coerce(float), vol.Range(min=1e-9)), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_MODE): vol.In([MODE_BOX, MODE_SLIDER]), @@ -93,7 +93,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_MAX): vol.Coerce(float), vol.Optional(CONF_INITIAL): vol.Coerce(float), vol.Optional(CONF_STEP, default=1): vol.All( - vol.Coerce(float), vol.Range(min=1e-3) + vol.Coerce(float), vol.Range(min=1e-9) ), vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, diff --git a/homeassistant/components/input_number/translations/zh-Hans.json b/homeassistant/components/input_number/translations/zh-Hans.json index b230db9fc60..4d976b841a8 100644 --- a/homeassistant/components/input_number/translations/zh-Hans.json +++ b/homeassistant/components/input_number/translations/zh-Hans.json @@ -1,3 +1,3 @@ { - "title": "\u6570\u503c\u9009\u62e9\u5668" + "title": "\u8f85\u52a9\u6570\u503c\u8f93\u5165\u5668" } \ No newline at end of file diff --git a/homeassistant/components/input_select/translations/zh-Hans.json b/homeassistant/components/input_select/translations/zh-Hans.json index 49380782fbd..365dadf0d09 100644 --- a/homeassistant/components/input_select/translations/zh-Hans.json +++ b/homeassistant/components/input_select/translations/zh-Hans.json @@ -1,3 +1,3 @@ { - "title": "\u591a\u9879\u9009\u62e9\u5668" + "title": "\u8f85\u52a9\u9009\u62e9\u5668" } \ No newline at end of file diff --git a/homeassistant/components/insteon/translations/ca.json b/homeassistant/components/insteon/translations/ca.json index 63601dd8071..59c711c3dae 100644 --- a/homeassistant/components/insteon/translations/ca.json +++ b/homeassistant/components/insteon/translations/ca.json @@ -29,7 +29,7 @@ }, "plm": { "data": { - "device": "Ruta del port USB del dispositiu" + "device": "Ruta del dispositiu USB" }, "description": "Configura el m\u00f2dem Insteon PowerLink (PLM).", "title": "Insteon PLM" diff --git a/homeassistant/components/insteon/translations/hu.json b/homeassistant/components/insteon/translations/hu.json index 8444aa97655..f34307a67a4 100644 --- a/homeassistant/components/insteon/translations/hu.json +++ b/homeassistant/components/insteon/translations/hu.json @@ -6,7 +6,7 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "select_single": "V\u00e1lassz egy lehet\u0151s\u00e9get" + "select_single": "V\u00e1lasszon egy lehet\u0151s\u00e9get" }, "step": { "hubv1": { @@ -25,7 +25,7 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Konfigur\u00e1lja az Insteon Hub 2. verzi\u00f3j\u00e1t.", - "title": "Insteon Hub 2. verzi\u00f3" + "title": "Insteon Hub Version 2" }, "plm": { "data": { @@ -38,7 +38,7 @@ "data": { "modem_type": "Modem t\u00edpusa." }, - "description": "V\u00e1laszd ki az Insteon modem t\u00edpus\u00e1t.", + "description": "V\u00e1lassza ki az Insteon modem t\u00edpus\u00e1t.", "title": "Insteon" } } @@ -47,14 +47,14 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "input_error": "\u00c9rv\u00e9nytelen bejegyz\u00e9sek, ellen\u0151rizze \u00e9rt\u00e9keket.", - "select_single": "V\u00e1lassz egy lehet\u0151s\u00e9get" + "select_single": "V\u00e1lasszon egy lehet\u0151s\u00e9get" }, "step": { "add_override": { "data": { - "address": "Eszk\u00f6z c\u00edme (azaz 1a2b3c)", - "cat": "Eszk\u00f6zkateg\u00f3ria (azaz 0x10)", - "subcat": "Eszk\u00f6z alkateg\u00f3ria (azaz 0x0a)" + "address": "Eszk\u00f6z c\u00edme (pl. 1a2b3c)", + "cat": "Eszk\u00f6zkateg\u00f3ria (pl. 0x10)", + "subcat": "Eszk\u00f6z alkateg\u00f3ria (pl. 0x0a)" }, "description": "Eszk\u00f6z-fel\u00fclb\u00edr\u00e1l\u00e1s hozz\u00e1ad\u00e1sa.", "title": "Insteon" diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d36e2da54c1..a2fd77fb4e1 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, PLATFORM_SCHEMA, - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, SensorEntity, ) from homeassistant.const import ( @@ -60,18 +60,21 @@ ICON = "mdi:chart-histogram" DEFAULT_ROUND = 3 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, - vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), - vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), - vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( - INTEGRATION_METHOD - ), - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_UNIT_OF_MEASUREMENT), + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), + vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( + INTEGRATION_METHOD + ), + } + ), ) @@ -106,7 +109,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Initialize the integration sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits - self._state = STATE_UNAVAILABLE + self._state = None self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" @@ -116,7 +119,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = unit_of_measurement self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = STATE_CLASS_TOTAL async def async_added_to_hass(self): """Handle entity which will be added.""" diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index d58efddeb3c..b93babf534e 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -1,6 +1,7 @@ """Support for IntesisHome and airconwithme Smart AC Controllers.""" import logging from random import randrange +from typing import NamedTuple from pyintesishome import IHAuthenticationError, IHConnectionError, IntesisHome import voluptuous as vol @@ -53,6 +54,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) + +class SwingSettings(NamedTuple): + """Settings for swing mode.""" + + vvane: str + hvane: str + + MAP_IH_TO_HVAC_MODE = { "auto": HVAC_MODE_HEAT_COOL, "cool": HVAC_MODE_COOL, @@ -73,10 +82,10 @@ MAP_PRESET_MODE_TO_IH = {v: k for k, v in MAP_IH_TO_PRESET_MODE.items()} IH_SWING_STOP = "auto/stop" IH_SWING_SWING = "swing" MAP_SWING_TO_IH = { - SWING_OFF: {"vvane": IH_SWING_STOP, "hvane": IH_SWING_STOP}, - SWING_BOTH: {"vvane": IH_SWING_SWING, "hvane": IH_SWING_SWING}, - SWING_HORIZONTAL: {"vvane": IH_SWING_STOP, "hvane": IH_SWING_SWING}, - SWING_VERTICAL: {"vvane": IH_SWING_SWING, "hvane": IH_SWING_STOP}, + SWING_OFF: SwingSettings(vvane=IH_SWING_STOP, hvane=IH_SWING_STOP), + SWING_BOTH: SwingSettings(vvane=IH_SWING_SWING, hvane=IH_SWING_SWING), + SWING_HORIZONTAL: SwingSettings(vvane=IH_SWING_STOP, hvane=IH_SWING_SWING), + SWING_VERTICAL: SwingSettings(vvane=IH_SWING_SWING, hvane=IH_SWING_STOP), } @@ -305,13 +314,12 @@ class IntesisAC(ClimateEntity): async def async_set_swing_mode(self, swing_mode): """Set the vertical vane.""" - swing_settings = MAP_SWING_TO_IH.get(swing_mode) - if swing_settings: + if swing_settings := MAP_SWING_TO_IH.get(swing_mode): await self._controller.set_vertical_vane( - self._device_id, swing_settings.get("vvane") + self._device_id, swing_settings.vvane ) await self._controller.set_horizontal_vane( - self._device_id, swing_settings.get("hvane") + self._device_id, swing_settings.hvane ) async def async_update(self): diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 6797da9d8a6..048107910d1 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,11 +1,11 @@ """Native Home Assistant iOS app component.""" import datetime +from http import HTTPStatus import voluptuous as vol from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, discovery @@ -333,7 +333,7 @@ class iOSIdentifyDeviceView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -348,6 +348,8 @@ class iOSIdentifyDeviceView(HomeAssistantView): try: save_json(self._config_path, hass.data[DOMAIN]) except HomeAssistantError: - return self.json_message("Error saving device.", HTTP_INTERNAL_SERVER_ERROR) + return self.json_message( + "Error saving device.", HTTPStatus.INTERNAL_SERVER_ERROR + ) return self.json({"status": "registered"}) diff --git a/homeassistant/components/ios/translations/fr.json b/homeassistant/components/ios/translations/fr.json index a6318718f94..85000f60a49 100644 --- a/homeassistant/components/ios/translations/fr.json +++ b/homeassistant/components/ios/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "Seule une configuration de Home Assistant iOS est n\u00e9cessaire." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { - "description": "Voulez-vous configurer le composant Home Assistant iOS?" + "description": "Voulez-vous commencer la configuration ?" } } } diff --git a/homeassistant/components/ios/translations/hu.json b/homeassistant/components/ios/translations/hu.json index dda7af8c541..06a80cc8c5e 100644 --- a/homeassistant/components/ios/translations/hu.json +++ b/homeassistant/components/ios/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/ios/translations/nl.json b/homeassistant/components/ios/translations/nl.json index 78757f9f715..1e660ec2f5d 100644 --- a/homeassistant/components/ios/translations/nl.json +++ b/homeassistant/components/ios/translations/nl.json @@ -5,7 +5,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py index db847f3dfe8..0b80e108238 100644 --- a/homeassistant/components/iotawatt/const.py +++ b/homeassistant/components/iotawatt/const.py @@ -9,4 +9,6 @@ DOMAIN = "iotawatt" VOLT_AMPERE_REACTIVE = "VAR" VOLT_AMPERE_REACTIVE_HOURS = "VARh" +ATTR_LAST_UPDATE = "last_update" + CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 1a722d52a1e..ada9c9fb346 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -1,7 +1,7 @@ """IoTaWatt DataUpdateCoordinator.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from iotawattpy.iotawatt import Iotawatt @@ -32,6 +32,16 @@ class IotawattUpdater(DataUpdateCoordinator): update_interval=timedelta(seconds=30), ) + self._last_run: datetime | None = None + + def update_last_run(self, last_run: datetime) -> None: + """Notify coordinator of a sensor last update time.""" + # We want to fetch the data from the iotawatt since HA was last shutdown. + # We retrieve from the sensor last updated. + # This method is called from each sensor upon their state being restored. + if self._last_run is None or last_run > self._last_run: + self._last_run = last_run + async def _async_update_data(self): """Fetch sensors from IoTaWatt device.""" if self.api is None: @@ -52,5 +62,6 @@ class IotawattUpdater(DataUpdateCoordinator): self.api = api - await self.api.update() + await self.api.update(lastUpdate=self._last_run) + self._last_run = None return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index d78e546d71f..42e1e074c8e 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -4,10 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iotawatt", "requirements": [ - "iotawattpy==0.0.8" + "iotawattpy==0.1.0" ], "codeowners": [ - "@gtdiehl" + "@gtdiehl", + "@jyavenard" ], "iot_class": "local_polling" } \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 1b4c166eb27..ec2918b0ce6 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -1,13 +1,15 @@ """Support for IoTaWatt Energy monitor.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable +import logging from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, SensorEntity, SensorEntityDescription, ) @@ -28,10 +30,19 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import entity, entity_registry, update_coordinator from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt -from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .const import ( + ATTR_LAST_UPDATE, + DOMAIN, + VOLT_AMPERE_REACTIVE, + VOLT_AMPERE_REACTIVE_HOURS, +) from .coordinator import IotawattUpdater +_LOGGER = logging.getLogger(__name__) + @dataclass class IotaWattSensorEntityDescription(SensorEntityDescription): @@ -72,6 +83,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "WattHours": IotaWattSensorEntityDescription( "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL, device_class=DEVICE_CLASS_ENERGY, ), "VA": IotaWattSensorEntityDescription( @@ -114,15 +126,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def _create_entity(key: str) -> IotaWattSensor: """Create a sensor entity.""" created.add(key) + data = coordinator.data["sensors"][key] + description = ENTITY_DESCRIPTION_KEY_MAP.get( + data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + ) + if data.getUnit() == "WattHours" and not data.getFromStart(): + return IotaWattAccumulatingSensor( + coordinator=coordinator, key=key, entity_description=description + ) + return IotaWattSensor( coordinator=coordinator, key=key, - mac_address=coordinator.data["sensors"][key].hub_mac_address, - name=coordinator.data["sensors"][key].getName(), - entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( - coordinator.data["sensors"][key].getUnit(), - IotaWattSensorEntityDescription("base_sensor"), - ), + entity_description=description, ) async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) @@ -145,16 +161,14 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Defines a IoTaWatt Energy Sensor.""" entity_description: IotaWattSensorEntityDescription - _attr_force_update = True + coordinator: IotawattUpdater def __init__( self, - coordinator, - key, - mac_address, - name, + coordinator: IotawattUpdater, + key: str, entity_description: IotaWattSensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator) @@ -196,17 +210,15 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): else: self.hass.async_create_task(self.async_remove()) return - super()._handle_coordinator_update() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the extra state attributes of the entity.""" data = self._sensor_data attrs = {"type": data.getType()} if attrs["type"] == "Input": attrs["channel"] = data.getChannel() - return attrs @property @@ -216,3 +228,77 @@ class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): return func(self._sensor_data.getValue()) return self._sensor_data.getValue() + + +class IotaWattAccumulatingSensor(IotaWattSensor, RestoreEntity): + """Defines a IoTaWatt Accumulative Energy (High Accuracy) Sensor.""" + + def __init__( + self, + coordinator: IotawattUpdater, + key: str, + entity_description: IotaWattSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + + super().__init__(coordinator, key, entity_description) + + if self._attr_unique_id is not None: + self._attr_unique_id += ".accumulated" + + self._accumulated_value: float | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + assert ( + self._accumulated_value is not None + ), "async_added_to_hass must have been called first" + self._accumulated_value += float(self._sensor_data.getValue()) + + super()._handle_coordinator_update() + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if self._accumulated_value is None: + return None + return round(self._accumulated_value, 1) + + async def async_added_to_hass(self) -> None: + """Load the last known state value of the entity if the accumulated type.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + self._accumulated_value = 0.0 + if state: + try: + # Previous value could be `unknown` if the connection didn't originally + # complete. + self._accumulated_value = float(state.state) + except (ValueError) as err: + _LOGGER.warning("Could not restore last state: %s", err) + else: + if ATTR_LAST_UPDATE in state.attributes: + last_run = dt.parse_datetime(state.attributes[ATTR_LAST_UPDATE]) + if last_run is not None: + self.coordinator.update_last_run(last_run) + # Force a second update from the iotawatt to ensure that sensors are up to date. + await self.coordinator.async_request_refresh() + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return f"{self._sensor_data.getSourceName()} Accumulated" + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the extra state attributes of the entity.""" + attrs = super().extra_state_attributes + + assert ( + self.coordinator.api is not None + and self.coordinator.api.getLastUpdateTime() is not None + ) + attrs[ATTR_LAST_UPDATE] = self.coordinator.api.getLastUpdateTime().isoformat() + + return attrs diff --git a/homeassistant/components/iotawatt/translations/ca.json b/homeassistant/components/iotawatt/translations/ca.json new file mode 100644 index 00000000000..d6a771b3f9b --- /dev/null +++ b/homeassistant/components/iotawatt/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "auth": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "El dispositiu IoTawatt necessita autenticaci\u00f3. Introdueix el nom d'usuari i la contrasenya i fes clic al bot\u00f3 Envia." + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/cs.json b/homeassistant/components/iotawatt/translations/cs.json new file mode 100644 index 00000000000..4223dcfb237 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "auth": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/de.json b/homeassistant/components/iotawatt/translations/de.json new file mode 100644 index 00000000000..b1dda29414b --- /dev/null +++ b/homeassistant/components/iotawatt/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "auth": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Das IoTawatt-Ger\u00e4t erfordert eine Authentifizierung. Bitte gib den Benutzernamen und das Passwort ein und klicke auf die Schaltfl\u00e4che Senden." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/el.json b/homeassistant/components/iotawatt/translations/el.json new file mode 100644 index 00000000000..44996764873 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/el.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "auth": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + }, + "description": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae IoTawatt \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2. \u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7 \u03ba\u03b1\u03b9 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03a5\u03c0\u03bf\u03b2\u03bf\u03bb\u03ae." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/en.json b/homeassistant/components/iotawatt/translations/en.json index cbda4b41bea..679fc6c6805 100644 --- a/homeassistant/components/iotawatt/translations/en.json +++ b/homeassistant/components/iotawatt/translations/en.json @@ -19,6 +19,5 @@ } } } - }, - "title": "iotawatt" + } } \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/es.json b/homeassistant/components/iotawatt/translations/es.json new file mode 100644 index 00000000000..00c04d7771f --- /dev/null +++ b/homeassistant/components/iotawatt/translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "La conexi\u00f3n ha fallado", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "auth": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "El dispositivo IoTawatt requiere autenticaci\u00f3n. Introduce el nombre de usuario y la contrase\u00f1a y haz clic en el bot\u00f3n Enviar." + }, + "user": { + "data": { + "host": "Anfitri\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/et.json b/homeassistant/components/iotawatt/translations/et.json new file mode 100644 index 00000000000..786e73a8858 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Viga tuvastamisel", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "auth": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "IoTawatt seade n\u00f5uab tuvastamist. Sisesta kasutajanimi ja salas\u00f5na ning kl\u00f5psa nuppu Edasta." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/he.json b/homeassistant/components/iotawatt/translations/he.json new file mode 100644 index 00000000000..ce440eb97d6 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "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": { + "auth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/hu.json b/homeassistant/components/iotawatt/translations/hu.json new file mode 100644 index 00000000000..52d46f97a84 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "auth": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Az IoTawatt eszk\u00f6z hiteles\u00edt\u00e9st ig\u00e9nyel. K\u00e9rj\u00fck, adja meg felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t, majd kattintson a K\u00fcld\u00e9s gombra." + }, + "user": { + "data": { + "host": "C\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/id.json b/homeassistant/components/iotawatt/translations/id.json new file mode 100644 index 00000000000..a48af7cd34d --- /dev/null +++ b/homeassistant/components/iotawatt/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "auth": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/it.json b/homeassistant/components/iotawatt/translations/it.json new file mode 100644 index 00000000000..ecb7d5b48af --- /dev/null +++ b/homeassistant/components/iotawatt/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "auth": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Il dispositivo IoTawatt richiede l'autenticazione. Inserisci il nome utente e la password e fai clic sul pulsante Invia." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/nl.json b/homeassistant/components/iotawatt/translations/nl.json new file mode 100644 index 00000000000..617073e91c0 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Kon niet verbinden", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "auth": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Het IoTawatt-apparaat vereist authenticatie. Voer de gebruikersnaam en het wachtwoord in en klik op de knop Verzenden." + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/no.json b/homeassistant/components/iotawatt/translations/no.json new file mode 100644 index 00000000000..bf350e5d7e5 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "auth": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "IoTawatt -enheten krever autentisering. Skriv inn brukernavn og passord og klikk p\u00e5 Send -knappen." + }, + "user": { + "data": { + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/pl.json b/homeassistant/components/iotawatt/translations/pl.json new file mode 100644 index 00000000000..2ea6be8ca81 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "auth": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/ru.json b/homeassistant/components/iotawatt/translations/ru.json new file mode 100644 index 00000000000..d0042988d99 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/ru.json @@ -0,0 +1,23 @@ +{ + "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.", + "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": { + "auth": { + "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": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e IoTawatt \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c." + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/te.json b/homeassistant/components/iotawatt/translations/te.json new file mode 100644 index 00000000000..1f494ec8005 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/te.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0c15\u0c28\u0c46\u0c15\u0c4d\u0c1f\u0c4d \u0c05\u0c35\u0c4d\u0c35\u0c21\u0c02 \u0c15\u0c41\u0c26\u0c30\u0c32\u0c47\u0c26\u0c41", + "invalid_auth": "\u0c38\u0c30\u0c3f\u0c15\u0c3e\u0c28\u0c3f \u0c2a\u0c4d\u0c30\u0c3e\u0c2e\u0c3e\u0c23\u0c3f\u0c15\u0c02", + "unknown": "\u0c05\u0c28\u0c41\u0c15\u0c4b\u0c28\u0c3f \u0c32\u0c4b\u0c2a\u0c02 " + }, + "step": { + "auth": { + "data": { + "username": "\u0c35\u0c3f\u0c28\u0c3f\u0c2f\u0c4b\u0c17\u0c26\u0c3e\u0c30\u0c41\u0c28\u0c3f \u0c2a\u0c47\u0c30\u0c41 " + }, + "description": "IoTawatt \u0c2a\u0c30\u0c3f\u0c15\u0c30\u0c3e\u0c28\u0c3f\u0c15\u0c3f \u0c2a\u0c4d\u0c30\u0c3e\u0c2e\u0c3e\u0c23\u0c40\u0c15\u0c30\u0c23 \u0c05\u0c35\u0c38\u0c30\u0c02. \u0c26\u0c2f\u0c1a\u0c47\u0c38\u0c3f \u0c35\u0c3f\u0c28\u0c3f\u0c2f\u0c4b\u0c17\u0c26\u0c3e\u0c30\u0c41 \u0c2a\u0c47\u0c30\u0c41 \u0c2e\u0c30\u0c3f\u0c2f\u0c41 \u0c2a\u0c3e\u0c38\u0c4d\u200c\u0c35\u0c30\u0c4d\u0c21\u0c4d\u200c\u0c28\u0c41 \u0c28\u0c2e\u0c4b\u0c26\u0c41 \u0c1a\u0c47\u0c38\u0c3f, \u0c38\u0c2e\u0c30\u0c4d\u0c2a\u0c3f\u0c02\u0c1a\u0c41 \u0c2c\u0c1f\u0c28\u0c4d\u200c\u0c28\u0c3f \u0c15\u0c4d\u0c32\u0c3f\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/th.json b/homeassistant/components/iotawatt/translations/th.json new file mode 100644 index 00000000000..705e334ef92 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/th.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0e01\u0e32\u0e23\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27", + "invalid_auth": "\u0e01\u0e32\u0e23\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2a\u0e34\u0e17\u0e18\u0e34\u0e4c\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07", + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e04\u0e34\u0e14" + }, + "step": { + "auth": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19", + "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49" + }, + "description": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c IoTawatt \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e01\u0e32\u0e23\u0e23\u0e31\u0e1a\u0e23\u0e2d\u0e07\u0e04\u0e27\u0e32\u0e21\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07 \u0e01\u0e23\u0e38\u0e13\u0e32\u0e43\u0e2a\u0e48\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49\u0e01\u0e31\u0e1a\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19 \u0e41\u0e25\u0e30\u0e04\u0e25\u0e34\u0e01\u0e1b\u0e38\u0e48\u0e21\u0e2a\u0e48\u0e07" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/zh-Hans.json b/homeassistant/components/iotawatt/translations/zh-Hans.json new file mode 100644 index 00000000000..7f36e76dc0a --- /dev/null +++ b/homeassistant/components/iotawatt/translations/zh-Hans.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u65e0\u6548\u51ed\u8bc1", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "auth": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "IoTawatt \u8bbe\u5907\u9700\u8981\u8eab\u4efd\u9a8c\u8bc1\u3002\u8bf7\u8f93\u5165\u7528\u6237\u540d\u548c\u5bc6\u7801\uff0c\u7136\u540e\u5355\u51fb\u63d0\u4ea4\u6309\u94ae\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/zh-Hant.json b/homeassistant/components/iotawatt/translations/zh-Hant.json new file mode 100644 index 00000000000..d30fb424935 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "auth": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "IoTawatt \u88dd\u7f6e\u9700\u8981\u8a8d\u8b49\uff0c\u8acb\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3001\u4e26\u9ede\u9078\u50b3\u9001\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index bd5aeac099a..04dabc013e7 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -1,11 +1,16 @@ """Support for Iperf3 network measurement tool.""" +from __future__ import annotations + from datetime import timedelta import logging import iperf3 import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_HOSTS, @@ -40,10 +45,19 @@ ATTR_UPLOAD = "upload" ATTR_VERSION = "Version" ATTR_HOST = "host" -SENSOR_TYPES = { - ATTR_DOWNLOAD: [ATTR_DOWNLOAD.capitalize(), DATA_RATE_MEGABITS_PER_SECOND], - ATTR_UPLOAD: [ATTR_UPLOAD.capitalize(), DATA_RATE_MEGABITS_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_DOWNLOAD, + name=ATTR_DOWNLOAD.capitalize(), + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + ), + SensorEntityDescription( + key=ATTR_UPLOAD, + name=ATTR_UPLOAD.capitalize(), + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PROTOCOLS = ["tcp", "udp"] @@ -62,9 +76,9 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.Schema( { vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA]), - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES) - ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] + ), vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 07b9cc069e4..dfc4abba707 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -1,5 +1,5 @@ """Support for Iperf3 sensors.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,42 +18,26 @@ ATTR_REMOTE_PORT = "Remote Port" async def async_setup_platform(hass, config, async_add_entities, discovery_info): """Set up the Iperf3 sensor.""" - sensors = [] - for iperf3_host in hass.data[IPERF3_DOMAIN].values(): - sensors.extend([Iperf3Sensor(iperf3_host, sensor) for sensor in discovery_info]) - async_add_entities(sensors, True) + entities = [ + Iperf3Sensor(iperf3_host, description) + for iperf3_host in hass.data[IPERF3_DOMAIN].values() + for description in SENSOR_TYPES + if description.key in discovery_info + ] + async_add_entities(entities, True) class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" - def __init__(self, iperf3_data, sensor_type): + _attr_icon = ICON + _attr_should_poll = False + + def __init__(self, iperf3_data, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = f"{SENSOR_TYPES[sensor_type][0]} {iperf3_data.host}" - self._state = None - self._sensor_type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.entity_description = description self._iperf3_data = iperf3_data - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return icon.""" - return ICON + self._attr_name = f"{description.name} {iperf3_data.host}" @property def extra_state_attributes(self): @@ -66,11 +50,6 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): ATTR_VERSION: self._iperf3_data.data[ATTR_VERSION], } - @property - def should_poll(self): - """Return the polling requirement for this sensor.""" - return False - async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() @@ -84,13 +63,13 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if not state: return - self._state = state.state + self._attr_native_value = state.state def update(self): """Get the latest data and update the states.""" - data = self._iperf3_data.data.get(self._sensor_type) + data = self._iperf3_data.data.get(self.entity_description.key) if data is not None: - self._state = round(data, 2) + self._attr_native_value = round(data, 2) @callback def _schedule_immediate_update(self, host): diff --git a/homeassistant/components/ipp/translations/fr.json b/homeassistant/components/ipp/translations/fr.json index 17d7375dbf4..21805c55330 100644 --- a/homeassistant/components/ipp/translations/fr.json +++ b/homeassistant/components/ipp/translations/fr.json @@ -18,10 +18,10 @@ "user": { "data": { "base_path": "Chemin d'acc\u00e8s relatif \u00e0 l'imprimante", - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port", - "ssl": "L'imprimante prend en charge la communication via SSL/TLS", - "verify_ssl": "L'imprimante utilise un certificat SSL appropri\u00e9" + "ssl": "Utilise un certificat SSL", + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Configurez votre imprimante via IPP (Internet Printing Protocol) pour l'int\u00e9grer \u00e0 Home Assistant", "title": "Reliez votre imprimante" diff --git a/homeassistant/components/ipp/translations/hu.json b/homeassistant/components/ipp/translations/hu.json index a024cfb2e56..18381fde2cf 100644 --- a/homeassistant/components/ipp/translations/hu.json +++ b/homeassistant/components/ipp/translations/hu.json @@ -13,21 +13,21 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "connection_upgrade": "Nem siker\u00fclt csatlakozni a nyomtat\u00f3hoz. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra az SSL/TLS opci\u00f3 bejel\u00f6l\u00e9s\u00e9vel." }, - "flow_title": "Nyomtat\u00f3: {name}", + "flow_title": "{name}", "step": { "user": { "data": { "base_path": "Relat\u00edv \u00fatvonal a nyomtat\u00f3hoz", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, - "description": "\u00c1ll\u00edtsa be a nyomtat\u00f3t az Internet Printing Protocol (IPP) protokollon kereszt\u00fcl, hogy integr\u00e1lhat\u00f3 legyen a Home Assistant seg\u00edts\u00e9g\u00e9vel.", + "description": "\u00c1ll\u00edtsa be a nyomtat\u00f3t az Internet Printing Protocol (IPP) protokollon kereszt\u00fcl, hogy integr\u00e1lhat\u00f3 legyen Home Assistant seg\u00edts\u00e9g\u00e9vel.", "title": "Kapcsolja \u00f6ssze a nyomtat\u00f3t" }, "zeroconf_confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?", "title": "Felfedezett nyomtat\u00f3" } } diff --git a/homeassistant/components/ipp/translations/id.json b/homeassistant/components/ipp/translations/id.json index c2b95751d4b..f65b853d671 100644 --- a/homeassistant/components/ipp/translations/id.json +++ b/homeassistant/components/ipp/translations/id.json @@ -13,7 +13,7 @@ "cannot_connect": "Gagal terhubung", "connection_upgrade": "Gagal terhubung ke printer. Coba lagi dengan mencentang opsi SSL/TLS." }, - "flow_title": "Printer: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 37cc7bedb71..0a782669846 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -1,16 +1,21 @@ """Support for IQVIA.""" +from __future__ import annotations + import asyncio +from collections.abc import Awaitable, Callable from datetime import timedelta from functools import partial +from typing import Any, Dict, cast from pyiqvia import Client from pyiqvia.errors import IQVIAError -from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION -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 import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -37,7 +42,7 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) PLATFORMS = ["sensor"] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IQVIA as config entry.""" hass.data.setdefault(DOMAIN, {}) coordinators = {} @@ -51,13 +56,17 @@ async def async_setup_entry(hass, entry): websession = aiohttp_client.async_get_clientsession(hass) client = Client(entry.data[CONF_ZIP_CODE], session=websession) - async def async_get_data_from_api(api_coro): + async def async_get_data_from_api( + api_coro: Callable[..., Awaitable] + ) -> dict[str, Any]: """Get data from a particular API coroutine.""" try: - return await api_coro() + data = await api_coro() except IQVIAError as err: raise UpdateFailed from err + return cast(Dict[str, Any], data) + init_data_update_tasks = [] for sensor_type, api_coro in ( (TYPE_ALLERGY_FORECAST, client.allergens.extended), @@ -90,7 +99,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 OpenUV config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -98,20 +107,22 @@ async def async_unload_entry(hass, entry): return unload_ok -class IQVIAEntity(CoordinatorEntity, SensorEntity): +class IQVIAEntity(CoordinatorEntity): """Define a base IQVIA entity.""" - def __init__(self, coordinator, entry, sensor_type, name, icon): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + description: EntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) 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_native_unit_of_measurement = "index" + self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{description.key}" self._entry = entry - self._type = sensor_type + self.entity_description = description @callback def _handle_coordinator_update(self) -> None: @@ -122,11 +133,11 @@ class IQVIAEntity(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() - if self._type == TYPE_ALLERGY_FORECAST: + if self.entity_description.key == TYPE_ALLERGY_FORECAST: self.async_on_remove( self.hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ TYPE_ALLERGY_OUTLOOK @@ -136,6 +147,6 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): 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/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 1e2a82813eb..32ce64014d7 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,9 +1,14 @@ """Config flow to configure the IQVIA component.""" +from __future__ import annotations + +from typing import Any + from pyiqvia import Client from pyiqvia.errors import InvalidZipError import voluptuous as vol from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_ZIP_CODE, DOMAIN @@ -14,11 +19,13 @@ class ConfigFlow(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_ZIP_CODE): str}) - 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) diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py index 10b2ae30220..cbcda26982e 100644 --- a/homeassistant/components/iqvia/const.py +++ b/homeassistant/components/iqvia/const.py @@ -21,14 +21,3 @@ TYPE_ASTHMA_TOMORROW = "asthma_index_tomorrow" TYPE_DISEASE_FORECAST = "disease_average_forecasted" TYPE_DISEASE_INDEX = "disease_index" TYPE_DISEASE_TODAY = "disease_index_today" - -SENSORS = { - TYPE_ALLERGY_FORECAST: ("Allergy Index: Forecasted Average", "mdi:flower"), - TYPE_ALLERGY_TODAY: ("Allergy Index: Today", "mdi:flower"), - TYPE_ALLERGY_TOMORROW: ("Allergy Index: Tomorrow", "mdi:flower"), - TYPE_ASTHMA_FORECAST: ("Asthma Index: Forecasted Average", "mdi:flower"), - TYPE_ASTHMA_TODAY: ("Asthma Index: Today", "mdi:flower"), - TYPE_ASTHMA_TOMORROW: ("Asthma Index: Tomorrow", "mdi:flower"), - TYPE_DISEASE_FORECAST: ("Cold & Flu: Forecasted Average", "mdi:snowflake"), - TYPE_DISEASE_TODAY: ("Cold & Flu Index: Today", "mdi:pill"), -} diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index e8914507657..7c0194a4896 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.21.1", "pyiqvia==1.1.0"], + "requirements": ["numpy==1.21.2", "pyiqvia==1.1.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 10d33bfb4bf..adf53a9cc05 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -1,16 +1,24 @@ """Support for IQVIA sensors.""" +from __future__ import annotations + from statistics import mean import numpy as np +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_STATE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IQVIAEntity from .const import ( DATA_COORDINATOR, DOMAIN, - SENSORS, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, @@ -53,40 +61,99 @@ RATING_MAPPING = [ {"label": "High", "minimum": 9.7, "maximum": 12}, ] + TREND_FLAT = "Flat" TREND_INCREASING = "Increasing" TREND_SUBSIDING = "Subsiding" -async def async_setup_entry(hass, entry, async_add_entities): +FORECAST_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_ALLERGY_FORECAST, + name="Allergy Index: Forecasted Average", + icon="mdi:flower", + ), + SensorEntityDescription( + key=TYPE_ASTHMA_FORECAST, + name="Asthma Index: Forecasted Average", + icon="mdi:flower", + ), + SensorEntityDescription( + key=TYPE_DISEASE_FORECAST, + name="Cold & Flu: Forecasted Average", + icon="mdi:snowflake", + ), +) + +INDEX_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_ALLERGY_TODAY, + name="Allergy Index: Today", + icon="mdi:flower", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_ALLERGY_TOMORROW, + name="Allergy Index: Tomorrow", + icon="mdi:flower", + ), + SensorEntityDescription( + key=TYPE_ASTHMA_TODAY, + name="Asthma Index: Today", + icon="mdi:flower", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_ASTHMA_TOMORROW, + name="Asthma Index: Tomorrow", + icon="mdi:flower", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=TYPE_DISEASE_TODAY, + name="Cold & Flu Index: Today", + icon="mdi:pill", + state_class=STATE_CLASS_MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up IQVIA sensors based on a config entry.""" - sensor_class_mapping = { - TYPE_ALLERGY_FORECAST: ForecastSensor, - TYPE_ALLERGY_TODAY: IndexSensor, - TYPE_ALLERGY_TOMORROW: IndexSensor, - TYPE_ASTHMA_FORECAST: ForecastSensor, - TYPE_ASTHMA_TODAY: IndexSensor, - TYPE_ASTHMA_TOMORROW: IndexSensor, - TYPE_DISEASE_FORECAST: ForecastSensor, - TYPE_DISEASE_TODAY: IndexSensor, - } - - sensors = [] - for sensor_type, (name, icon) in SENSORS.items(): - api_category = API_CATEGORY_MAPPING.get(sensor_type, sensor_type) - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][api_category] - sensor_class = sensor_class_mapping[sensor_type] - - sensors.append(sensor_class(coordinator, entry, sensor_type, name, icon)) + sensors: list[ForecastSensor | IndexSensor] = [ + ForecastSensor( + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ + API_CATEGORY_MAPPING.get(description.key, description.key) + ], + entry, + description, + ) + for description in FORECAST_SENSOR_DESCRIPTIONS + ] + sensors.extend( + [ + IndexSensor( + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ + API_CATEGORY_MAPPING.get(description.key, description.key) + ], + entry, + description, + ) + for description in INDEX_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) -def calculate_trend(indices): +@callback +def calculate_trend(indices: list[float]) -> str: """Calculate the "moving average" of a set of indices.""" index_range = np.arange(0, len(indices)) index_array = np.array(indices) - linear_fit = np.polyfit(index_range, index_array, 1) + linear_fit = np.polyfit(index_range, index_array, 1) # type: ignore slope = round(linear_fit[0], 2) if slope > 0: @@ -98,11 +165,11 @@ def calculate_trend(indices): return TREND_FLAT -class ForecastSensor(IQVIAEntity): +class ForecastSensor(IQVIAEntity, SensorEntity): """Define sensor related to forecast data.""" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" if not self.available: return @@ -131,7 +198,7 @@ class ForecastSensor(IQVIAEntity): } ) - if self._type == TYPE_ALLERGY_FORECAST: + if self.entity_description.key == TYPE_ALLERGY_FORECAST: outlook_coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR][ self._entry.entry_id ][TYPE_ALLERGY_OUTLOOK] @@ -147,26 +214,32 @@ class ForecastSensor(IQVIAEntity): ] = outlook_coordinator.data.get("Season") -class IndexSensor(IQVIAEntity): +class IndexSensor(IQVIAEntity, SensorEntity): """Define sensor related to indices.""" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" if not self.coordinator.last_update_success: return try: - if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + if self.entity_description.key in ( + TYPE_ALLERGY_TODAY, + TYPE_ALLERGY_TOMORROW, + ): data = self.coordinator.data.get("Location") - elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + elif self.entity_description.key in ( + TYPE_ASTHMA_TODAY, + TYPE_ASTHMA_TOMORROW, + ): data = self.coordinator.data.get("Location") - elif self._type == TYPE_DISEASE_TODAY: + elif self.entity_description.key == TYPE_DISEASE_TODAY: data = self.coordinator.data.get("Location") except KeyError: return - key = self._type.split("_")[-1].title() + key = self.entity_description.key.split("_")[-1].title() try: [period] = [p for p in data["periods"] if p["Type"] == key] @@ -188,7 +261,7 @@ class IndexSensor(IQVIAEntity): } ) - if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + if self.entity_description.key in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): for idx, attrs in enumerate(period["Triggers"]): index = idx + 1 self._attr_extra_state_attributes.update( @@ -198,7 +271,7 @@ class IndexSensor(IQVIAEntity): f"{ATTR_ALLERGEN_TYPE}_{index}": attrs["PlantType"], } ) - elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + elif self.entity_description.key in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): for idx, attrs in enumerate(period["Triggers"]): index = idx + 1 self._attr_extra_state_attributes.update( @@ -207,7 +280,7 @@ class IndexSensor(IQVIAEntity): f"{ATTR_ALLERGEN_AMOUNT}_{index}": attrs["PPM"], } ) - elif self._type == TYPE_DISEASE_TODAY: + elif self.entity_description.key == TYPE_DISEASE_TODAY: for attrs in period["Triggers"]: self._attr_extra_state_attributes[ f"{attrs['Name'].lower()}_index" diff --git a/homeassistant/components/iqvia/translations/fr.json b/homeassistant/components/iqvia/translations/fr.json index 22f45ac2f0e..a967ff490e8 100644 --- a/homeassistant/components/iqvia/translations/fr.json +++ b/homeassistant/components/iqvia/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce code postal a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_zip_code": "Code postal invalide" diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e3d11efd739..02bbea29bdb 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -41,7 +41,6 @@ from .const import ( MANUFACTURER, PLATFORMS, PROGRAM_PLATFORMS, - UNDO_UPDATE_LISTENER, ) from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables from .services import async_setup_services, async_unload_services @@ -218,9 +217,7 @@ async def async_setup_entry( await hass.async_add_executor_job(_start_auto_update) - undo_listener = entry.add_update_listener(_async_update_listener) - - hass_isy_data[UNDO_UPDATE_LISTENER] = undo_listener + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_auto_update) ) @@ -290,8 +287,6 @@ async def async_unload_entry( await hass.async_add_executor_job(_stop_auto_update) - hass_isy_data[UNDO_UPDATE_LISTENER]() - if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 58e5238cbee..34c7a40cfc0 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -182,9 +182,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), }, ) - 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): diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index b7b2f283a84..8e634006ec2 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -183,8 +183,6 @@ TYPE_CATEGORY_X10 = "113." TYPE_EZIO2X4 = "7.3.255." TYPE_INSTEON_MOTION = ("16.1.", "16.22.") -UNDO_UPDATE_LISTENER = "undo_update_listener" - # Used for discovery UDN_UUID_PREFIX = "uuid:" ISY_URL_POSTFIX = "/desc" diff --git a/homeassistant/components/isy994/translations/es.json b/homeassistant/components/isy994/translations/es.json index 46dc3260f83..324b94e9938 100644 --- a/homeassistant/components/isy994/translations/es.json +++ b/homeassistant/components/isy994/translations/es.json @@ -9,7 +9,7 @@ "invalid_host": "La entrada del host no estaba en formato URL completo, por ejemplo, http://192.168.10.100:80", "unknown": "Error inesperado" }, - "flow_title": "Dispositivos Universales ISY994 {nombre} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/isy994/translations/fr.json b/homeassistant/components/isy994/translations/fr.json index 0bd04cd14b1..8a4e4ffa707 100644 --- a/homeassistant/components/isy994/translations/fr.json +++ b/homeassistant/components/isy994/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", - "invalid_auth": "Autentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "invalid_host": "L'entr\u00e9e d'h\u00f4te n'\u00e9tait pas au format URL complet, par exemple http://192.168.10.100:80", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/isy994/translations/hu.json b/homeassistant/components/isy994/translations/hu.json index dab85300e6d..d9cce2fefcb 100644 --- a/homeassistant/components/isy994/translations/hu.json +++ b/homeassistant/components/isy994/translations/hu.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "invalid_host": "A gazdag\u00e9p bejegyz\u00e9se nem volt teljes URL-form\u00e1tumban, p\u00e9ld\u00e1ul: http://192.168.10.100:80", + "invalid_host": "A c\u00edm bejegyz\u00e9se nem volt teljes URL-form\u00e1tumban, p\u00e9ld\u00e1ul: http://192.168.10.100:80", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name} ({host})", @@ -18,7 +18,7 @@ "tls": "Az ISY vez\u00e9rl\u0151 TLS verzi\u00f3ja.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "A gazdag\u00e9p bejegyz\u00e9s\u00e9nek teljes URL form\u00e1tumban kell lennie, pl. Http://192.168.10.100:80", + "description": "A c\u00edm bejegyz\u00e9s\u00e9nek teljes URL form\u00e1tumban kell lennie, pl. Http://192.168.10.100:80", "title": "Csatlakozzon az ISY994-hez" } } @@ -40,7 +40,7 @@ "system_health": { "info": { "device_connected": "ISY csatlakozik", - "host_reachable": "El\u00e9rhet\u0151 gazdag\u00e9p", + "host_reachable": "C\u00edm el\u00e9rhet\u0151", "last_heartbeat": "Utols\u00f3 sz\u00edvver\u00e9s ideje", "websocket_status": "Esem\u00e9nySocket \u00e1llapota" } diff --git a/homeassistant/components/isy994/translations/id.json b/homeassistant/components/isy994/translations/id.json index fec6d1090b0..099e3607d1e 100644 --- a/homeassistant/components/isy994/translations/id.json +++ b/homeassistant/components/isy994/translations/id.json @@ -9,7 +9,7 @@ "invalid_host": "Entri host tidak dalam format URL lengkap, misalnya, http://192.168.10.100:80", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Universal Devices ISY994 {name} ({host})", + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/izone/translations/fr.json b/homeassistant/components/izone/translations/fr.json index 0c6faf83e6e..eb86962e11b 100644 --- a/homeassistant/components/izone/translations/fr.json +++ b/homeassistant/components/izone/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique iZone trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration d'iZone est n\u00e9cessaire." + "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": { diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 4e90dd00058..6a57eb0eeda 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -22,7 +22,7 @@ DATA_SENSORS = ( SensorEntityDescription( key="date", name="Date", - icon="mdi:judaism", + icon="mdi:star-david", ), SensorEntityDescription( key="weekly_portion", @@ -230,9 +230,11 @@ class JewishCalendarSensor(SensorEntity): # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha if self.entity_description.key == "holiday": - self._holiday_attrs["id"] = after_shkia_date.holiday_name - self._holiday_attrs["type"] = after_shkia_date.holiday_type.name - self._holiday_attrs["type_id"] = after_shkia_date.holiday_type.value + self._holiday_attrs = { + "id": after_shkia_date.holiday_name, + "type": after_shkia_date.holiday_type.name, + "type_id": after_shkia_date.holiday_type.value, + } return after_shkia_date.holiday_description if self.entity_description.key == "omer_count": return after_shkia_date.omer_day diff --git a/homeassistant/components/juicenet/translations/ca.json b/homeassistant/components/juicenet/translations/ca.json index f5df6921062..01a3a0bcae4 100644 --- a/homeassistant/components/juicenet/translations/ca.json +++ b/homeassistant/components/juicenet/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/juicenet/translations/fr.json b/homeassistant/components/juicenet/translations/fr.json index 2448ec6263c..f4e5bfa53d5 100644 --- a/homeassistant/components/juicenet/translations/fr.json +++ b/homeassistant/components/juicenet/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Ce compte JuiceNet est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "api_token": "Jeton d'API JuiceNet" + "api_token": "Jeton d'API" }, "description": "Vous aurez besoin du jeton API de https://home.juice.net/Manage.", "title": "Se connecter \u00e0 JuiceNet" diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index d79f2591525..8da8034a162 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -1,9 +1,9 @@ """The Keenetic Client class.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import Callable from ndms2_client import Client, ConnectionException, Device, TelnetConnection from ndms2_client.client import RouterInfo diff --git a/homeassistant/components/keenetic_ndms2/translations/ca.json b/homeassistant/components/keenetic_ndms2/translations/ca.json index 0acb0ef0266..748a55885e4 100644 --- a/homeassistant/components/keenetic_ndms2/translations/ca.json +++ b/homeassistant/components/keenetic_ndms2/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "no_udn": "La informaci\u00f3 de descobriment SSDP no t\u00e9 UDN", "not_keenetic_ndms2": "El dispositiu descobert no \u00e9s un router Keenetic" }, diff --git a/homeassistant/components/keenetic_ndms2/translations/hu.json b/homeassistant/components/keenetic_ndms2/translations/hu.json index c2327130a11..2575d832863 100644 --- a/homeassistant/components/keenetic_ndms2/translations/hu.json +++ b/homeassistant/components/keenetic_ndms2/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/keenetic_ndms2/translations/id.json b/homeassistant/components/keenetic_ndms2/translations/id.json index bb30e715579..900745bc29e 100644 --- a/homeassistant/components/keenetic_ndms2/translations/id.json +++ b/homeassistant/components/keenetic_ndms2/translations/id.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/keenetic_ndms2/translations/ru.json b/homeassistant/components/keenetic_ndms2/translations/ru.json index 3c7eed4be01..810c2bfff05 100644 --- a/homeassistant/components/keenetic_ndms2/translations/ru.json +++ b/homeassistant/components/keenetic_ndms2/translations/ru.json @@ -25,7 +25,7 @@ "step": { "user": { "data": { - "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\"", + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", "include_arp": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 ARP (\u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u044e\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u0435 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430)", "include_associated": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 \u0442\u043e\u0447\u0435\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u0430 WiFi (\u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u044e\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0434\u0430\u043d\u043d\u044b\u0435 hotspot)", "interfaces": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u044b \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f", diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index f32f825acc4..c1c83f81b36 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -32,6 +32,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.event import async_track_time_interval @@ -123,7 +124,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= mode = get_ip_mode(host) mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - unique_id = f"kef-{mac}" if mac is not None else None + if mac is None: + raise PlatformNotReady("Cannot get the ip address of kef speaker.") + + unique_id = f"kef-{mac}" media_player = KefMediaPlayer( name, diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 1d16dd12cc2..1c62dcb7575 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -18,11 +18,13 @@ DEVICE_DESCRIPTOR = "device_descriptor" DEVICE_ID_GROUP = "Device description" DEVICE_NAME = "device_name" DOMAIN = "keyboard_remote" +VALUE = "value" ICON = "mdi:remote" KEY_CODE = "key_code" KEY_VALUE = {"key_up": 0, "key_down": 1, "key_hold": 2} +KEY_VALUE_NAME = {value: key for key, value in KEY_VALUE.items()} KEYBOARD_REMOTE_COMMAND_RECEIVED = "keyboard_remote_command_received" KEYBOARD_REMOTE_CONNECTED = "keyboard_remote_connected" KEYBOARD_REMOTE_DISCONNECTED = "keyboard_remote_disconnected" @@ -236,7 +238,12 @@ class KeyboardRemote: while True: self.hass.bus.async_fire( KEYBOARD_REMOTE_COMMAND_RECEIVED, - {KEY_CODE: code, DEVICE_DESCRIPTOR: path, DEVICE_NAME: name}, + { + KEY_CODE: code, + TYPE: "key_hold", + DEVICE_DESCRIPTOR: path, + DEVICE_NAME: name, + }, ) await asyncio.sleep(repeat) @@ -294,6 +301,7 @@ class KeyboardRemote: KEYBOARD_REMOTE_COMMAND_RECEIVED, { KEY_CODE: event.code, + TYPE: KEY_VALUE_NAME[event.value], DEVICE_DESCRIPTOR: dev.path, DEVICE_NAME: dev.name, }, diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index b63873bd165..1fc34f47000 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -3,6 +3,6 @@ "name": "Keyboard Remote", "documentation": "https://www.home-assistant.io/integrations/keyboard_remote", "requirements": ["evdev==1.4.0", "aionotify==0.2.0"], - "codeowners": ["@bendavid"], + "codeowners": ["@bendavid", "@lanrat"], "iot_class": "local_push" } diff --git a/homeassistant/components/kmtronic/translations/hu.json b/homeassistant/components/kmtronic/translations/hu.json index 4fe9a3875e6..3ea79e3bd89 100644 --- a/homeassistant/components/kmtronic/translations/hu.json +++ b/homeassistant/components/kmtronic/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 5d32726474c..85cd6c9a60f 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -1,8 +1,9 @@ """Support for KNX/IP covers.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime -from typing import Any, Callable +from typing import Any from xknx import XKNX from xknx.devices import Cover as XknxCover, Device as XknxDevice diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 408ab25e7cc..b4b15c977fd 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -1,7 +1,7 @@ """Exposures to KNX bus.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from xknx import XKNX from xknx.devices import DateTime, ExposeSensor diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 65ff6b3b8fa..89dab40958c 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODES from homeassistant.components.cover import DEVICE_CLASSES as COVER_DEVICE_CLASSES -from homeassistant.components.sensor import STATE_CLASSES_SCHEMA +from homeassistant.components.sensor import CONF_STATE_CLASS, STATE_CLASSES_SCHEMA from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, @@ -730,7 +730,6 @@ class SensorSchema(KNXPlatformSchema): CONF_ALWAYS_CALLBACK = "always_callback" CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_STATE_CLASS = "state_class" CONF_SYNC_STATE = CONF_SYNC_STATE DEFAULT_NAME = "KNX Sensor" diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 933ba7bf30d..c1f68e4c376 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -6,7 +6,11 @@ from typing import Any from xknx import XKNX from xknx.devices import Sensor as XknxSensor -from homeassistant.components.sensor import DEVICE_CLASSES, SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES, + SensorEntity, +) from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -63,7 +67,7 @@ class KNXSensor(KnxEntity, SensorEntity): self._attr_force_update = self._device.always_callback self._attr_unique_id = str(self._device.sensor_value.group_address_state) self._attr_native_unit_of_measurement = self._device.unit_of_measurement() - self._attr_state_class = config.get(SensorSchema.CONF_STATE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index ac474413b54..68735bfa386 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( ATTR_ENTITY_ID, @@ -69,9 +72,9 @@ def _attach_trigger( config: ConfigType, action: AutomationActionType, event_type, - automation_info: dict, + automation_info: AutomationTriggerInfo, ): - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] job = HassJob(action) @callback @@ -90,7 +93,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "turn_on": diff --git a/homeassistant/components/kodi/translations/fr.json b/homeassistant/components/kodi/translations/fr.json index a7c4b3f34a1..8e740466bc4 100644 --- a/homeassistant/components/kodi/translations/fr.json +++ b/homeassistant/components/kodi/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification erron\u00e9e", + "invalid_auth": "Authentification invalide", "no_uuid": "L'instance Kodi n'a pas d'identifiant unique. Cela est probablement d\u00fb \u00e0 une ancienne version de Kodi (17.x ou inf\u00e9rieure). Vous pouvez configurer l'int\u00e9gration manuellement ou passer \u00e0 une version plus r\u00e9cente de Kodi.", "unknown": "Erreur inattendue" }, @@ -29,7 +29,7 @@ "data": { "host": "H\u00f4te", "port": "Port", - "ssl": "Connexion via SSL" + "ssl": "Utilise un certificat SSL" }, "description": "Informations de connexion Kodi. Veuillez vous assurer d'activer \"Autoriser le contr\u00f4le de Kodi via HTTP\" dans Syst\u00e8me / Param\u00e8tres / R\u00e9seau / Services." }, diff --git a/homeassistant/components/kodi/translations/hu.json b/homeassistant/components/kodi/translations/hu.json index 9ae1e0741d5..e561bd5d6a4 100644 --- a/homeassistant/components/kodi/translations/hu.json +++ b/homeassistant/components/kodi/translations/hu.json @@ -19,15 +19,15 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Add meg a Kodi felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t. Ezek megtal\u00e1lhat\u00f3k a Rendszer/Be\u00e1ll\u00edt\u00e1sok/H\u00e1l\u00f3zat/Szolg\u00e1ltat\u00e1sok r\u00e9szben." + "description": "Adja meg a Kodi felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t. Ezek megtal\u00e1lhat\u00f3k a Rendszer/Be\u00e1ll\u00edt\u00e1sok/H\u00e1l\u00f3zat/Szolg\u00e1ltat\u00e1sok r\u00e9szben." }, "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a Kodi (`{name}`)-t a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a Kodi (`{name}`)-t Home Assistanthoz?", "title": "Felfedezett Kodi" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata" }, diff --git a/homeassistant/components/kodi/translations/id.json b/homeassistant/components/kodi/translations/id.json index 1a81ab72fab..16ce1e2c43b 100644 --- a/homeassistant/components/kodi/translations/id.json +++ b/homeassistant/components/kodi/translations/id.json @@ -12,7 +12,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Kodi: {name}", + "flow_title": "{name}", "step": { "credentials": { "data": { diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 6785e2e7124..29502f3878c 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -1,6 +1,7 @@ """Support for Konnected devices.""" import copy import hmac +from http import HTTPStatus import json import logging @@ -28,9 +29,6 @@ from homeassistant.const import ( CONF_SWITCHES, CONF_TYPE, CONF_ZONE, - HTTP_BAD_REQUEST, - HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, STATE_OFF, STATE_ON, ) @@ -325,7 +323,9 @@ class KonnectedView(HomeAssistantView): (True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)), False, ): - return self.json_message("unauthorized", status_code=HTTP_UNAUTHORIZED) + return self.json_message( + "unauthorized", status_code=HTTPStatus.UNAUTHORIZED + ) try: # Konnected 2.2.0 and above supports JSON payloads payload = await request.json() @@ -339,7 +339,7 @@ class KonnectedView(HomeAssistantView): device = data[CONF_DEVICES].get(device_id) if device is None: return self.json_message( - "unregistered device", status_code=HTTP_BAD_REQUEST + "unregistered device", status_code=HTTPStatus.BAD_REQUEST ) panel = device.get("panel") @@ -364,7 +364,7 @@ class KonnectedView(HomeAssistantView): if zone_data is None: return self.json_message( - "unregistered sensor/actuator", status_code=HTTP_BAD_REQUEST + "unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST ) zone_data["device_id"] = device_id @@ -385,7 +385,7 @@ class KonnectedView(HomeAssistantView): device = data[CONF_DEVICES].get(device_id) if not device: return self.json_message( - f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND + f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND ) panel = device.get("panel") @@ -417,7 +417,7 @@ class KonnectedView(HomeAssistantView): ) return self.json_message( f"Switch on zone or pin {target} not configured", - status_code=HTTP_NOT_FOUND, + status_code=HTTPStatus.NOT_FOUND, ) resp = {} diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index a22b30f6862..ae43e771068 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -1,5 +1,7 @@ """Support for DHT and DS18B20 sensors attached to a Konnected device.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( CONF_DEVICES, CONF_NAME, @@ -16,9 +18,19 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW -SENSOR_TYPES = { - DEVICE_CLASS_TEMPERATURE: ["Temperature", TEMP_CELSIUS], - DEVICE_CLASS_HUMIDITY: ["Humidity", PERCENTAGE], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "temperature": SensorEntityDescription( + key=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "humidity": SensorEntityDescription( + key=DEVICE_CLASS_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), } @@ -26,7 +38,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] device_id = config_entry.data["id"] - sensors = [] # Initialize all DHT sensors. dht_sensors = [ @@ -34,11 +45,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in data[CONF_DEVICES][device_id][CONF_SENSORS] if sensor[CONF_TYPE] == "dht" ] - for sensor in dht_sensors: - sensors.append(KonnectedSensor(device_id, sensor, DEVICE_CLASS_TEMPERATURE)) - sensors.append(KonnectedSensor(device_id, sensor, DEVICE_CLASS_HUMIDITY)) + entities = [ + KonnectedSensor(device_id, data=sensor_config, description=description) + for sensor_config in dht_sensors + for description in SENSOR_TYPES.values() + ] - async_add_entities(sensors) + async_add_entities(entities) @callback def async_add_ds18b20(attrs): @@ -57,7 +70,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): KonnectedSensor( device_id, sensor_config, - DEVICE_CLASS_TEMPERATURE, + SENSOR_TYPES["temperature"], addr=attrs.get("addr"), initial_state=attrs.get("temp"), ) @@ -73,15 +86,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class KonnectedSensor(SensorEntity): """Represents a Konnected DHT Sensor.""" - def __init__(self, device_id, data, sensor_type, addr=None, initial_state=None): + def __init__( + self, + device_id, + data, + description: SensorEntityDescription, + addr=None, + initial_state=None, + ): """Initialize the entity for a single sensor_type.""" + self.entity_description = description self._addr = addr self._data = data - self._device_id = device_id - self._type = sensor_type self._zone_num = self._data.get(CONF_ZONE) - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._unique_id = addr or f"{device_id}-{self._zone_num}-{sensor_type}" + self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}" # set initial state if known at initialization self._state = initial_state @@ -89,38 +107,20 @@ class KonnectedSensor(SensorEntity): self._state = round(float(self._state), 1) # set entity name if given - self._name = self._data.get(CONF_NAME) - if self._name: - self._name += f" {SENSOR_TYPES[sensor_type][0]}" + if name := self._data.get(CONF_NAME): + name += f" {description.name}" + self._attr_name = name - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_device_info = {"identifiers": {(KONNECTED_DOMAIN, device_id)}} @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_info(self): - """Return the device info.""" - return {"identifiers": {(KONNECTED_DOMAIN, self._device_id)}} - async def async_added_to_hass(self): """Store entity_id and register state change callback.""" - entity_id_key = self._addr or self._type + entity_id_key = self._addr or self.entity_description.key self._data[entity_id_key] = self.entity_id async_dispatcher_connect( self.hass, f"konnected.{self.entity_id}.update", self.async_set_state @@ -129,7 +129,7 @@ class KonnectedSensor(SensorEntity): @callback def async_set_state(self, state): """Update the sensor's state.""" - if self._type == DEVICE_CLASS_HUMIDITY: + if self.entity_description.key == DEVICE_CLASS_HUMIDITY: self._state = int(float(state)) else: self._state = round(float(state), 1) diff --git a/homeassistant/components/konnected/translations/fr.json b/homeassistant/components/konnected/translations/fr.json index 3c020967c8d..7d50c474909 100644 --- a/homeassistant/components/konnected/translations/fr.json +++ b/homeassistant/components/konnected/translations/fr.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_konn_panel": "Non reconnu comme appareil Konnected.io", - "unknown": "Une erreur inconnue s'est produite" + "unknown": "Erreur inattendue" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 Konnected Panel sur {host} : {port}" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "confirm": { diff --git a/homeassistant/components/konnected/translations/hu.json b/homeassistant/components/konnected/translations/hu.json index 1ad58223b88..f5431480ebb 100644 --- a/homeassistant/components/konnected/translations/hu.json +++ b/homeassistant/components/konnected/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, @@ -11,7 +11,7 @@ }, "step": { "confirm": { - "description": "Modell: {model}\nAzonos\u00edt\u00f3: {id}\nGazdag\u00e9p: {host}\nPort: {port} \n\n Az IO \u00e9s a panel viselked\u00e9s\u00e9t a Konnected Alarm Panel be\u00e1ll\u00edt\u00e1saiban konfigur\u00e1lhatja.", + "description": "Modell: {model}\nAzonos\u00edt\u00f3: {id}\nC\u00edm: {host}\nPort: {port} \n\nAz IO \u00e9s a panel viselked\u00e9s\u00e9t a Konnected Alarm Panel be\u00e1ll\u00edt\u00e1saiban konfigur\u00e1lhatja.", "title": "Konnected eszk\u00f6z k\u00e9sz" }, "import_confirm": { @@ -23,7 +23,7 @@ "host": "IP c\u00edm", "port": "Port" }, - "description": "K\u00e9rj\u00fck, adja meg a Konnected Panel gazdag\u00e9p\u00e9nek adatait." + "description": "K\u00e9rj\u00fck, adja meg a Konnected Panel csatlakoz\u00e1si adatait." } } }, diff --git a/homeassistant/components/konnected/translations/id.json b/homeassistant/components/konnected/translations/id.json index 633e6bba2df..b80b86c25c9 100644 --- a/homeassistant/components/konnected/translations/id.json +++ b/homeassistant/components/konnected/translations/id.json @@ -78,7 +78,7 @@ "alarm2_out2": "OUT2/ALARM2", "out1": "OUT1" }, - "description": "Pilih konfigurasi I/O lainnya di bawah ini. Anda dapat mengonfigurasi detail opsi pada langkah berikutnya.", + "description": "Pilih konfigurasi I/O lainnya di bawah ini. Anda dapat mengonfigurasi detail opsi pada langkah berikutnya.", "title": "Konfigurasikan I/O yang Diperluas" }, "options_misc": { diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 9f902da7d2f..68c2baffbdb 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -10,8 +10,12 @@ from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, @@ -135,6 +139,28 @@ SENSOR_PROCESS_DATA = [ }, "format_round", ), + ( + "devices:local:pv1", + "U", + "DC1 Voltage", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_round", + ), + ( + "devices:local:pv1", + "I", + "DC1 Current", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_float", + ), ( "devices:local:pv2", "P", @@ -146,6 +172,28 @@ SENSOR_PROCESS_DATA = [ }, "format_round", ), + ( + "devices:local:pv2", + "U", + "DC2 Voltage", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_round", + ), + ( + "devices:local:pv2", + "I", + "DC2 Current", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_float", + ), ( "devices:local:pv3", "P", @@ -157,6 +205,28 @@ SENSOR_PROCESS_DATA = [ }, "format_round", ), + ( + "devices:local:pv3", + "U", + "DC3 Voltage", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_round", + ), + ( + "devices:local:pv3", + "I", + "DC3 Current", + { + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "format_float", + ), ( "devices:local", "PV2Bat_P", diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index eb4f6ce44a6..2a21cb4ee55 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -231,6 +231,14 @@ class PlenticoreDataFormatter: except (TypeError, ValueError): return state + @staticmethod + def format_float(state: str) -> int | str: + """Return the given state value as float rounded to three decimal places.""" + try: + return round(float(state), 3) + except (TypeError, ValueError): + return state + @staticmethod def format_energy(state: str) -> float | str: """Return the given state value as energy value, scaled to kWh.""" diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 19ac4db0f90..15971cec68d 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -1,9 +1,10 @@ """Platform for Kostal Plenticore sensors.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Callable +from typing import Any from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/kostal_plenticore/translations/fr.json b/homeassistant/components/kostal_plenticore/translations/fr.json index 08a75486d7f..a06ade90e9a 100644 --- a/homeassistant/components/kostal_plenticore/translations/fr.json +++ b/homeassistant/components/kostal_plenticore/translations/fr.json @@ -5,13 +5,13 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Erreur inattendue", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "password": "Mot de passe" } } diff --git a/homeassistant/components/kostal_plenticore/translations/hu.json b/homeassistant/components/kostal_plenticore/translations/hu.json index b235578e9c3..3ffe413a82b 100644 --- a/homeassistant/components/kostal_plenticore/translations/hu.json +++ b/homeassistant/components/kostal_plenticore/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3" } } diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 76a4976f163..5b1fd2626e3 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -127,7 +127,7 @@ class KrakenData: self._config_entry, options=options ) await self._async_refresh_tradable_asset_pairs() - # Wait 1 second to avoid triggering the CallRateLimiter + # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) self.coordinator = DataUpdateCoordinator( self._hass, @@ -139,6 +139,8 @@ class KrakenData: ), ) await self.coordinator.async_config_entry_first_refresh() + # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter + await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) def _get_websocket_name_asset_pairs(self) -> str: return ",".join(wsname for wsname in self.tradable_asset_pairs.values()) diff --git a/homeassistant/components/kraken/const.py b/homeassistant/components/kraken/const.py index 2272d12ead6..7382510efd0 100644 --- a/homeassistant/components/kraken/const.py +++ b/homeassistant/components/kraken/const.py @@ -1,17 +1,29 @@ """Constants for the kraken integration.""" - from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Dict, TypedDict -KrakenResponse = Dict[str, Dict[str, float]] +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -class SensorType(TypedDict): - """SensorType class.""" +class KrakenResponseEntry(TypedDict): + """Dict describing a single response entry.""" - name: str - enabled_by_default: bool + ask: tuple[float, float, float] + bid: tuple[float, float, float] + last_trade_closed: tuple[float, float] + volume: tuple[float, float] + volume_weighted_average: tuple[float, float] + number_of_trades: tuple[int, int] + low: tuple[float, float] + high: tuple[float, float] + opening_price: float + + +KrakenResponse = Dict[str, KrakenResponseEntry] DEFAULT_SCAN_INTERVAL = 60 @@ -22,21 +34,94 @@ CONF_TRACKED_ASSET_PAIRS = "tracked_asset_pairs" DOMAIN = "kraken" -SENSOR_TYPES: list[SensorType] = [ - {"name": "ask", "enabled_by_default": True}, - {"name": "ask_volume", "enabled_by_default": False}, - {"name": "bid", "enabled_by_default": True}, - {"name": "bid_volume", "enabled_by_default": False}, - {"name": "volume_today", "enabled_by_default": False}, - {"name": "volume_last_24h", "enabled_by_default": False}, - {"name": "volume_weighted_average_today", "enabled_by_default": False}, - {"name": "volume_weighted_average_last_24h", "enabled_by_default": False}, - {"name": "number_of_trades_today", "enabled_by_default": False}, - {"name": "number_of_trades_last_24h", "enabled_by_default": False}, - {"name": "last_trade_closed", "enabled_by_default": False}, - {"name": "low_today", "enabled_by_default": True}, - {"name": "low_last_24h", "enabled_by_default": False}, - {"name": "high_today", "enabled_by_default": True}, - {"name": "high_last_24h", "enabled_by_default": False}, - {"name": "opening_price_today", "enabled_by_default": False}, -] + +@dataclass +class KrakenRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] + + +@dataclass +class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): + """Describes Kraken sensor entity.""" + + +SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( + KrakenSensorEntityDescription( + key="ask", + value_fn=lambda x, y: x.data[y]["ask"][0], + ), + KrakenSensorEntityDescription( + key="ask_volume", + value_fn=lambda x, y: x.data[y]["ask"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="bid", + value_fn=lambda x, y: x.data[y]["bid"][0], + ), + KrakenSensorEntityDescription( + key="bid_volume", + value_fn=lambda x, y: x.data[y]["bid"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_today", + value_fn=lambda x, y: x.data[y]["volume"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_last_24h", + value_fn=lambda x, y: x.data[y]["volume"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_weighted_average_today", + value_fn=lambda x, y: x.data[y]["volume_weighted_average"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_weighted_average_last_24h", + value_fn=lambda x, y: x.data[y]["volume_weighted_average"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="number_of_trades_today", + value_fn=lambda x, y: x.data[y]["number_of_trades"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="number_of_trades_last_24h", + value_fn=lambda x, y: x.data[y]["number_of_trades"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="last_trade_closed", + value_fn=lambda x, y: x.data[y]["last_trade_closed"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="low_today", + value_fn=lambda x, y: x.data[y]["low"][0], + ), + KrakenSensorEntityDescription( + key="low_last_24h", + value_fn=lambda x, y: x.data[y]["low"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="high_today", + value_fn=lambda x, y: x.data[y]["high"][0], + ), + KrakenSensorEntityDescription( + key="high_last_24h", + value_fn=lambda x, y: x.data[y]["high"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="opening_price_today", + value_fn=lambda x, y: x.data[y]["opening_price"], + entity_registry_enabled_default=False, + ), +) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 1b9f8ca13cc..b7d38d4796b 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -2,15 +2,14 @@ from __future__ import annotations import logging +from typing import Optional from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import KrakenData @@ -19,7 +18,8 @@ from .const import ( DISPATCH_CONFIG_UPDATED, DOMAIN, SENSOR_TYPES, - SensorType, + KrakenResponse, + KrakenSensorEntityDescription, ) _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup_entry( ) } - sensors = [] + entities = [] for tracked_asset_pair in config_entry.options[CONF_TRACKED_ASSET_PAIRS]: # Only create new devices if ( @@ -51,15 +51,17 @@ async def async_setup_entry( ) in existing_devices: existing_devices.pop(device_name) else: - for sensor_type in SENSOR_TYPES: - sensors.append( + entities.extend( + [ KrakenSensor( hass.data[DOMAIN], tracked_asset_pair, - sensor_type, + description, ) - ) - async_add_entities(sensors, True) + for description in SENSOR_TYPES + ] + ) + async_add_entities(entities, True) # Remove devices for asset pairs which are no longer tracked for device_id in existing_devices.values(): @@ -76,57 +78,46 @@ async def async_setup_entry( ) -class KrakenSensor(CoordinatorEntity, SensorEntity): +class KrakenSensor(CoordinatorEntity[Optional[KrakenResponse]], SensorEntity): """Define a Kraken sensor.""" + entity_description: KrakenSensorEntityDescription + def __init__( self, kraken_data: KrakenData, tracked_asset_pair: str, - sensor_type: SensorType, + description: KrakenSensorEntityDescription, ) -> None: """Initialize.""" assert kraken_data.coordinator is not None super().__init__(kraken_data.coordinator) + self.entity_description = description self.tracked_asset_pair_wsname = kraken_data.tradable_asset_pairs[ tracked_asset_pair ] - self._source_asset = tracked_asset_pair.split("/")[0] + source_asset = tracked_asset_pair.split("/")[0] self._target_asset = tracked_asset_pair.split("/")[1] - self._sensor_type = sensor_type["name"] - self._enabled_by_default = sensor_type["enabled_by_default"] - self._unit_of_measurement = self._target_asset - self._device_name = f"{self._source_asset} {self._target_asset}" - self._name = "_".join( + if "number_of" not in description.key: + self._attr_native_unit_of_measurement = self._target_asset + self._device_name = f"{source_asset} {self._target_asset}" + self._attr_name = "_".join( [ tracked_asset_pair.split("/")[0], tracked_asset_pair.split("/")[1], - sensor_type["name"], + description.key, ] ) + self._attr_unique_id = self._attr_name.lower() self._received_data_at_least_once = False self._available = True - self._state = 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_by_default - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def unique_id(self) -> str: - """Set unique_id for sensor.""" - return self._name.lower() - - @property - def native_value(self) -> StateType: - """Return the state.""" - return self._state + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{source_asset}_{self._target_asset}")}, + "name": self._device_name, + "manufacturer": "Kraken.com", + "entry_type": "service", + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -138,73 +129,14 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): super()._handle_coordinator_update() def _update_internal_state(self) -> None: + if not self.coordinator.data: + return try: - if self._sensor_type == "last_trade_closed": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "last_trade_closed" - ][0] - if self._sensor_type == "ask": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "ask" - ][0] - if self._sensor_type == "ask_volume": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "ask" - ][1] - if self._sensor_type == "bid": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "bid" - ][0] - if self._sensor_type == "bid_volume": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "bid" - ][1] - if self._sensor_type == "volume_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume" - ][0] - if self._sensor_type == "volume_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume" - ][1] - if self._sensor_type == "volume_weighted_average_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume_weighted_average" - ][0] - if self._sensor_type == "volume_weighted_average_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "volume_weighted_average" - ][1] - if self._sensor_type == "number_of_trades_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "number_of_trades" - ][0] - if self._sensor_type == "number_of_trades_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "number_of_trades" - ][1] - if self._sensor_type == "low_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "low" - ][0] - if self._sensor_type == "low_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "low" - ][1] - if self._sensor_type == "high_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "high" - ][0] - if self._sensor_type == "high_last_24h": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "high" - ][1] - if self._sensor_type == "opening_price_today": - self._state = self.coordinator.data[self.tracked_asset_pair_wsname][ - "opening_price" - ] - self._received_data_at_least_once = True # Received data at least one time. - except TypeError: + self._attr_native_value = self.entity_description.value_fn( + self.coordinator, self.tracked_asset_pair_wsname # type: ignore[arg-type] + ) + self._received_data_at_least_once = True + except KeyError: if self._received_data_at_least_once: if self._available: _LOGGER.warning( @@ -228,29 +160,11 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): return "mdi:currency-btc" return "mdi:cash" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - if "number_of" not in self._sensor_type: - return self._unit_of_measurement - return None - @property def available(self) -> bool: """Could the api be accessed during the last update call.""" return self._available and self.coordinator.last_update_success - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - - return { - "identifiers": {(DOMAIN, f"{self._source_asset}_{self._target_asset}")}, - "name": self._device_name, - "manufacturer": "Kraken.com", - "entry_type": "service", - } - def create_device_name(tracked_asset_pair: str) -> str: """Create the device name for a given tracked asset pair.""" diff --git a/homeassistant/components/kraken/translations/es.json b/homeassistant/components/kraken/translations/es.json index 1befa14a52b..86df8397c15 100644 --- a/homeassistant/components/kraken/translations/es.json +++ b/homeassistant/components/kraken/translations/es.json @@ -1,11 +1,15 @@ { "config": { + "abort": { + "already_configured": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, "step": { "user": { "data": { "one": "", "other": "Otros" - } + }, + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" } } }, diff --git a/homeassistant/components/kraken/translations/hu.json b/homeassistant/components/kraken/translations/hu.json index 793a3433eb8..6ea1c832188 100644 --- a/homeassistant/components/kraken/translations/hu.json +++ b/homeassistant/components/kraken/translations/hu.json @@ -13,7 +13,7 @@ "one": "\u00dcres", "other": "\u00dcres" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, diff --git a/homeassistant/components/kraken/translations/id.json b/homeassistant/components/kraken/translations/id.json new file mode 100644 index 00000000000..a436ac4aee5 --- /dev/null +++ b/homeassistant/components/kraken/translations/id.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "user": { + "description": "Ingin memulai penyiapan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/nl.json b/homeassistant/components/kraken/translations/nl.json index 25fe63bebd5..09b93b205e3 100644 --- a/homeassistant/components/kraken/translations/nl.json +++ b/homeassistant/components/kraken/translations/nl.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/kulersky/translations/fr.json b/homeassistant/components/kulersky/translations/fr.json index 42f356ac365..e9ae4e0b644 100644 --- a/homeassistant/components/kulersky/translations/fr.json +++ b/homeassistant/components/kulersky/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun appareil n'a \u00e9t\u00e9 d\u00e9tect\u00e9 sur le r\u00e9seau", - "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Seulement une seule configuration est possible " + "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": { diff --git a/homeassistant/components/kulersky/translations/hu.json b/homeassistant/components/kulersky/translations/hu.json index 6c61530acbe..a56ebbfc906 100644 --- a/homeassistant/components/kulersky/translations/hu.json +++ b/homeassistant/components/kulersky/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/kulersky/translations/nl.json b/homeassistant/components/kulersky/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/kulersky/translations/nl.json +++ b/homeassistant/components/kulersky/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 9b4b0e5cdfc..f850b39a620 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -2,7 +2,7 @@ "domain": "lastfm", "name": "Last.fm", "documentation": "https://www.home-assistant.io/integrations/lastfm", - "requirements": ["pylast==4.2.0"], + "requirements": ["pylast==4.2.1"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 9db564812a8..48a63a50fa9 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -1,8 +1,8 @@ """Support for LCN devices.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Callable import pypck diff --git a/homeassistant/components/life360/translations/ca.json b/homeassistant/components/life360/translations/ca.json index cf57e4e1d2f..875692a661a 100644 --- a/homeassistant/components/life360/translations/ca.json +++ b/homeassistant/components/life360/translations/ca.json @@ -8,7 +8,7 @@ "default": "Per configurar les opcions avan\u00e7ades mira la [documentaci\u00f3 de Life360]({docs_url})." }, "error": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "invalid_username": "Nom d'usuari incorrecte", "unknown": "Error inesperat" diff --git a/homeassistant/components/lifx/translations/fr.json b/homeassistant/components/lifx/translations/fr.json index 837ca29a314..f8cc0a9dddd 100644 --- a/homeassistant/components/lifx/translations/fr.json +++ b/homeassistant/components/lifx/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique LIFX trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de LIFX est possible." + "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": { diff --git a/homeassistant/components/lifx/translations/hu.json b/homeassistant/components/lifx/translations/hu.json index f706dcefa96..3d728f21d07 100644 --- a/homeassistant/components/lifx/translations/hu.json +++ b/homeassistant/components/lifx/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a LIFX-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: LIFX?" } } } diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index 6cb6e8a34c1..6714ee4cf9c 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -22,7 +25,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 77e5742bbab..7cc6b9c572c 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Iterable import logging from types import MappingProxyType -from typing import Any, cast +from typing import Any, NamedTuple, cast from homeassistant.const import ( ATTR_ENTITY_ID, @@ -71,14 +71,22 @@ COLOR_GROUP = [ ATTR_KELVIN, ] + +class ColorModeAttr(NamedTuple): + """Map service data parameter to state attribute for a color mode.""" + + parameter: str + state_attr: str + + COLOR_MODE_TO_ATTRIBUTE = { - COLOR_MODE_COLOR_TEMP: (ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), - COLOR_MODE_HS: (ATTR_HS_COLOR, ATTR_HS_COLOR), - COLOR_MODE_RGB: (ATTR_RGB_COLOR, ATTR_RGB_COLOR), - COLOR_MODE_RGBW: (ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), - COLOR_MODE_RGBWW: (ATTR_RGBWW_COLOR, ATTR_RGBWW_COLOR), - COLOR_MODE_WHITE: (ATTR_WHITE, ATTR_BRIGHTNESS), - COLOR_MODE_XY: (ATTR_XY_COLOR, ATTR_XY_COLOR), + COLOR_MODE_COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), + COLOR_MODE_HS: ColorModeAttr(ATTR_HS_COLOR, ATTR_HS_COLOR), + COLOR_MODE_RGB: ColorModeAttr(ATTR_RGB_COLOR, ATTR_RGB_COLOR), + COLOR_MODE_RGBW: ColorModeAttr(ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), + COLOR_MODE_RGBWW: ColorModeAttr(ATTR_RGBWW_COLOR, ATTR_RGBWW_COLOR), + COLOR_MODE_WHITE: ColorModeAttr(ATTR_WHITE, ATTR_BRIGHTNESS), + COLOR_MODE_XY: ColorModeAttr(ATTR_XY_COLOR, ATTR_XY_COLOR), } DEPRECATED_GROUP = [ @@ -162,17 +170,18 @@ async def _async_reproduce_state( # Remove deprecated white value if we got a valid color mode service_data.pop(ATTR_WHITE_VALUE, None) color_mode = state.attributes[ATTR_COLOR_MODE] - if parameter_state := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - parameter, state_attr = parameter_state - if state_attr not in state.attributes: + if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + if color_mode_attr.state_attr not in state.attributes: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - state_attr, + color_mode_attr.state_attr, state.entity_id, ) return - service_data[parameter] = state.attributes[state_attr] + service_data[color_mode_attr.parameter] = state.attributes[ + color_mode_attr.state_attr + ] else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 6dbaf0c3b7c..ac307f68d08 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -179,6 +179,7 @@ def state(new_state): def wrapper(self, **kwargs): """Wrap a group state change.""" + # pylint: disable=protected-access pipeline = Pipeline() transition_time = DEFAULT_TRANSITION diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 3a9930c5e70..21b7927ebe2 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -1,5 +1,5 @@ """Trigger an automation when a LiteJet switch is released.""" -from typing import Callable +from collections.abc import Callable import voluptuous as vol @@ -31,7 +31,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] number = config.get(CONF_NUMBER) held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) diff --git a/homeassistant/components/litterrobot/translations/ca.json b/homeassistant/components/litterrobot/translations/ca.json index b7ca6053fbc..5165473860a 100644 --- a/homeassistant/components/litterrobot/translations/ca.json +++ b/homeassistant/components/litterrobot/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/litterrobot/translations/hu.json b/homeassistant/components/litterrobot/translations/hu.json index fd8db27da5e..cc0c820facf 100644 --- a/homeassistant/components/litterrobot/translations/hu.json +++ b/homeassistant/components/litterrobot/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", diff --git a/homeassistant/components/local_ip/translations/fr.json b/homeassistant/components/local_ip/translations/fr.json index 554ecef2380..d7d0eef17c1 100644 --- a/homeassistant/components/local_ip/translations/fr.json +++ b/homeassistant/components/local_ip/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration d'IP locale est autoris\u00e9e" + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "user": { diff --git a/homeassistant/components/local_ip/translations/hu.json b/homeassistant/components/local_ip/translations/hu.json index e930d58784a..cfb92ddb7b6 100644 --- a/homeassistant/components/local_ip/translations/hu.json +++ b/homeassistant/components/local_ip/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "Helyi IP c\u00edm" } } diff --git a/homeassistant/components/local_ip/translations/nl.json b/homeassistant/components/local_ip/translations/nl.json index 3ea8140a96e..4b2672d2a3b 100644 --- a/homeassistant/components/local_ip/translations/nl.json +++ b/homeassistant/components/local_ip/translations/nl.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Lokaal IP-adres" } } diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 97df92a9f89..52cfc7900ba 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -12,7 +12,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, ) @@ -78,7 +77,7 @@ async def handle_webhook(hass, webhook_id, request): if direction == "enter": async_dispatcher_send(hass, TRACKER_UPDATE, device, gps_location, location_name) - return web.Response(text=f"Setting location to {location_name}", status=HTTP_OK) + return web.Response(text=f"Setting location to {location_name}") if direction == "exit": current_state = hass.states.get(f"{DEVICE_TRACKER}.{device}") @@ -88,7 +87,7 @@ async def handle_webhook(hass, webhook_id, request): async_dispatcher_send( hass, TRACKER_UPDATE, device, gps_location, location_name ) - return web.Response(text="Setting location to not home", status=HTTP_OK) + return web.Response(text="Setting location to not home") # Ignore the message if it is telling us to exit a zone that we # aren't currently in. This occurs when a zone is entered @@ -96,13 +95,12 @@ async def handle_webhook(hass, webhook_id, request): # be sent first, then the exit message will be sent second. return web.Response( text=f"Ignoring exit from {location_name} (already in {current_state})", - status=HTTP_OK, ) if direction == "test": # In the app, a test message can be sent. Just return something to # the user to let them know that it works. - return web.Response(text="Received test message.", status=HTTP_OK) + return web.Response(text="Received test message.") _LOGGER.error("Received unidentified message from Locative: %s", direction) return web.Response( diff --git a/homeassistant/components/locative/translations/fr.json b/homeassistant/components/locative/translations/fr.json index d17ede01d2e..9c9414caf9b 100644 --- a/homeassistant/components/locative/translations/fr.json +++ b/homeassistant/components/locative/translations/fr.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "\u00cates-vous s\u00fbr de vouloir configurer le Webhook Locative ?", + "description": "Voulez-vous commencer la configuration ?", "title": "Configurer le Locative Webhook" } } diff --git a/homeassistant/components/locative/translations/hu.json b/homeassistant/components/locative/translations/hu.json index 8dc03e9c37a..893e22f1471 100644 --- a/homeassistant/components/locative/translations/hu.json +++ b/homeassistant/components/locative/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "Ha helyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Locative alkalmaz\u00e1sban. \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})." }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "Locative Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/locative/translations/nl.json b/homeassistant/components/locative/translations/nl.json index d66a1262b5d..ed39d00430b 100644 --- a/homeassistant/components/locative/translations/nl.json +++ b/homeassistant/components/locative/translations/nl.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Stel de Locative Webhook in" } } diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 393fd968437..cbdab7abb3d 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -80,7 +83,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "jammed": diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 8bbfd08314d..91739aa5990 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -1,6 +1,7 @@ """Event parser and human readable log generator.""" from contextlib import suppress from datetime import timedelta +from http import HTTPStatus from itertools import groupby import json import re @@ -32,7 +33,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, - HTTP_BAD_REQUEST, ) from homeassistant.core import DOMAIN as HA_DOMAIN, callback, split_entity_id from homeassistant.exceptions import InvalidEntityFormatError @@ -198,7 +198,7 @@ class LogbookView(HomeAssistantView): datetime = dt_util.parse_datetime(datetime) if datetime is None: - return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) + return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) else: datetime = dt_util.start_of_local_day() @@ -226,7 +226,7 @@ class LogbookView(HomeAssistantView): start_day = datetime end_day = dt_util.parse_datetime(end_time) if end_day is None: - return self.json_message("Invalid end_time", HTTP_BAD_REQUEST) + return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] @@ -235,7 +235,7 @@ class LogbookView(HomeAssistantView): if entity_ids and context_id: return self.json_message( - "Can't combine entity with context_id", HTTP_BAD_REQUEST + "Can't combine entity with context_id", HTTPStatus.BAD_REQUEST ) def json_events(): diff --git a/homeassistant/components/logger/services.yaml b/homeassistant/components/logger/services.yaml index 1995a027b0b..5930a4e5d9e 100644 --- a/homeassistant/components/logger/services.yaml +++ b/homeassistant/components/logger/services.yaml @@ -18,61 +18,3 @@ set_default_level: set_level: name: Set level description: Set log level for integrations. - fields: - homeassistant.core: - name: Home Assistant Core - description: - "Example on how to change the logging level for a Home Assistant Core - integrations." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' - homeassistant.components.mqtt: - name: Home Assistant components mqtt - description: - "Example on how to change the logging level for an Integration." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' - custom_components.my_integration: - name: Custom components "my_integation" - description: - "Example on how to change the logging level for a Custom Integration." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' - aiohttp: - name: aioHttp - description: - "Example on how to change the logging level for a Python module." - selector: - select: - options: - - 'debug' - - 'critical' - - 'error' - - 'fatal' - - 'info' - - 'warn' - - 'warning' diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index d61de1ea017..9054b476332 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure Logi Circle component.""" import asyncio from collections import OrderedDict +from http import HTTPStatus import async_timeout from logi_circle import LogiCircle @@ -14,7 +15,6 @@ from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_SENSORS, - HTTP_BAD_REQUEST, ) from homeassistant.core import callback @@ -201,5 +201,6 @@ class LogiCircleAuthCallbackView(HomeAssistantView): ) return self.json_message("Authorisation code saved") return self.json_message( - "Authorisation code missing from query string", status_code=HTTP_BAD_REQUEST + "Authorisation code missing from query string", + status_code=HTTPStatus.BAD_REQUEST, ) diff --git a/homeassistant/components/logi_circle/translations/ca.json b/homeassistant/components/logi_circle/translations/ca.json index 9f46b3f621a..da66dbf55dd 100644 --- a/homeassistant/components/logi_circle/translations/ca.json +++ b/homeassistant/components/logi_circle/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "external_error": "S'ha produ\u00eft una excepci\u00f3 d'un altre flux de dades.", "external_setup": "Logi Circle s'ha configurat correctament des d'un altre flux de dades.", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3." diff --git a/homeassistant/components/logi_circle/translations/hu.json b/homeassistant/components/logi_circle/translations/hu.json index 73522a59519..f79ab3944dc 100644 --- a/homeassistant/components/logi_circle/translations/hu.json +++ b/homeassistant/components/logi_circle/translations/hu.json @@ -4,16 +4,16 @@ "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "external_error": "Kiv\u00e9tel t\u00f6rt\u00e9nt egy m\u00e1sik folyamatb\u00f3l.", "external_setup": "LogiCircle sikeresen konfigur\u00e1lva egy m\u00e1sik folyamatb\u00f3l.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "error": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { "auth": { - "description": "K\u00e9rj\u00fck, k\u00f6vesse az al\u00e1bbi linket, \u00e9s ** Fogadja el ** a LogiCircle -fi\u00f3kj\u00e1hoz val\u00f3 hozz\u00e1f\u00e9r\u00e9st, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot. \n\n [Link] ({authorization_url})", + "description": "K\u00e9rj\u00fck, k\u00f6vesse az al\u00e1bbi linket, \u00e9s ** Fogadja el ** a LogiCircle -fi\u00f3kj\u00e1hoz val\u00f3 hozz\u00e1f\u00e9r\u00e9st, majd t\u00e9rjen vissza, \u00e9s nyomja meg az al\u00e1bbi ** K\u00fcld\u00e9s ** gombot. \n\n [Link]({authorization_url})", "title": "Hiteles\u00edt\u00e9s a LogiCircle seg\u00edts\u00e9g\u00e9vel" }, "user": { diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 93b127259d2..bb043028ae6 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -69,10 +69,12 @@ class LovelaceConfig(ABC): async def async_save(self, config): """Save config.""" + # pylint: disable=no-self-use raise HomeAssistantError("Not supported") async def async_delete(self): """Delete config.""" + # pylint: disable=no-self-use raise HomeAssistantError("Not supported") @callback diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index 5dffab65d75..ea09c9208ee 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -1,10 +1,13 @@ """Support for Luftdaten stations.""" +from __future__ import annotations + import logging from luftdaten import Luftdaten from luftdaten.exceptions import LuftdatenError import voluptuous as vol +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -47,49 +50,53 @@ SENSOR_TEMPERATURE = "temperature" TOPIC_UPDATE = f"{DOMAIN}_data_update" -SENSORS = { - 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, - ], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + name="Humidity", + icon="mdi:water-percent", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESSURE, + name="Pressure", + icon="mdi:arrow-down-bold", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_PRESSURE_AT_SEALEVEL, + name="Pressure at sealevel", + icon="mdi:download", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_PM10, + name="PM10", + icon="mdi:thought-bubble", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key=SENSOR_PM2_5, + name="PM2.5", + icon="mdi:thought-bubble-outline", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SENSOR_SCHEMA = vol.Schema( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -174,7 +181,7 @@ async def async_setup_entry(hass, config_entry): luftdaten = LuftDatenData( Luftdaten(config_entry.data[CONF_SENSOR_ID], hass.loop, session), config_entry.data.get(CONF_SENSORS, {}).get( - CONF_MONITORED_CONDITIONS, list(SENSORS) + CONF_MONITORED_CONDITIONS, SENSOR_KEYS ), ) await luftdaten.async_update() diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index b4bdd7f30b3..aa4995490ca 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -1,7 +1,5 @@ """Support for Luftdaten sensors.""" -import logging - -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, @@ -16,87 +14,54 @@ from . import ( DATA_LUFTDATEN_CLIENT, DEFAULT_ATTRIBUTION, DOMAIN, - SENSORS, + SENSOR_TYPES, TOPIC_UPDATE, ) from .const import ATTR_SENSOR_ID -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, entry, async_add_entities): """Set up a Luftdaten sensor based on a config entry.""" luftdaten = hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][entry.entry_id] - sensors = [] - for sensor_type in luftdaten.sensor_conditions: - try: - name, icon, unit, device_class = SENSORS[sensor_type] - except KeyError: - _LOGGER.debug("Unknown sensor value type: %s", sensor_type) - continue + entities = [ + LuftdatenSensor(luftdaten, description, entry.data[CONF_SHOW_ON_MAP]) + for description in SENSOR_TYPES + if description.key in luftdaten.sensor_conditions + ] - sensors.append( - LuftdatenSensor( - luftdaten, - sensor_type, - name, - icon, - unit, - device_class, - entry.data[CONF_SHOW_ON_MAP], - ) - ) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class LuftdatenSensor(SensorEntity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, sensor_type, name, icon, unit, device_class, show): + _attr_should_poll = False + + def __init__(self, luftdaten, description: SensorEntityDescription, show): """Initialize the Luftdaten sensor.""" + self.entity_description = description self._async_unsub_dispatcher_connect = None self.luftdaten = luftdaten - self._icon = icon - self._name = name self._data = None - self.sensor_type = sensor_type - self._unit_of_measurement = unit self._show_on_map = show self._attrs = {} - self._attr_device_class = device_class - - @property - def icon(self): - """Return the icon.""" - return self._icon @property def native_value(self): """Return the state of the device.""" if self._data is not None: try: - return self._data[self.sensor_type] + return self._data[self.entity_description.key] except KeyError: return None - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def should_poll(self): - """Disable polling.""" - return False - @property def unique_id(self) -> str: """Return a unique, friendly identifier for this entity.""" if self._data is not None: try: - return f"{self._data['sensor_id']}_{self.sensor_type}" + return f"{self._data['sensor_id']}_{self.entity_description.key}" except KeyError: return None diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 163789d19bd..6541925a5e4 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -2,7 +2,7 @@ "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", "documentation": "https://www.home-assistant.io/integrations/lupusec", - "requirements": ["lupupy==0.0.18"], + "requirements": ["lupupy==0.0.21"], "codeowners": ["@majuss"], "iot_class": "local_polling" } diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index de8ff228bc4..c0f378d19d7 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -131,6 +131,9 @@ class LutronDevice(Entity): @property def unique_id(self): """Return a unique ID.""" + # Temporary fix for https://github.com/thecynic/pylutron/issues/70 + if self._lutron_device.uuid is None: + return None return f"{self._controller.guid}_{self._lutron_device.uuid}" diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 8a7f321e158..4e378942bd8 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -258,7 +261,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device = get_button_device_by_dr_id(hass, config[CONF_DEVICE_ID]) diff --git a/homeassistant/components/lutron_caseta/translations/es.json b/homeassistant/components/lutron_caseta/translations/es.json index 9dbedba1457..098a90377d8 100644 --- a/homeassistant/components/lutron_caseta/translations/es.json +++ b/homeassistant/components/lutron_caseta/translations/es.json @@ -69,8 +69,8 @@ "stop_all": "Detener todo" }, "trigger_type": { - "press": "\"{subtipo}\" presionado", - "release": "\"{subtipo}\" liberado" + "press": "\"{subtype}\" presionado", + "release": "\"{subtype}\" liberado" } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/fr.json b/homeassistant/components/lutron_caseta/translations/fr.json index ff561548b44..0dcc8755173 100644 --- a/homeassistant/components/lutron_caseta/translations/fr.json +++ b/homeassistant/components/lutron_caseta/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Pont Cas\u00e9ta d\u00e9j\u00e0 configur\u00e9.", - "cannot_connect": "Installation annul\u00e9e du pont Cas\u00e9ta en raison d'un \u00e9chec de connexion.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", "not_lutron_device": "L'appareil d\u00e9couvert n'est pas un appareil Lutron" }, "error": { - "cannot_connect": "\u00c9chec de la connexion \u00e0 la passerelle Cas\u00e9ta; v\u00e9rifiez la configuration de votre h\u00f4te et de votre certificat." + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "Lutron Cas\u00e9ta {name} ( {host} )", "step": { @@ -20,7 +20,7 @@ }, "user": { "data": { - "host": "Hote" + "host": "H\u00f4te" }, "description": "Saisissez l'adresse IP de l'appareil.", "title": "Se connecter automatiquement au pont" diff --git a/homeassistant/components/lutron_caseta/translations/hu.json b/homeassistant/components/lutron_caseta/translations/hu.json index 0e8960530e3..f3fca2ff705 100644 --- a/homeassistant/components/lutron_caseta/translations/hu.json +++ b/homeassistant/components/lutron_caseta/translations/hu.json @@ -15,12 +15,12 @@ "title": "Nem siker\u00fclt import\u00e1lni a Cas\u00e9ta h\u00edd konfigur\u00e1ci\u00f3j\u00e1t." }, "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.", + "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" + "host": "C\u00edm" }, "description": "Add meg az eszk\u00f6z IP-c\u00edm\u00e9t.", "title": "Automatikus csatlakoz\u00e1s a h\u00eddhoz" diff --git a/homeassistant/components/lutron_caseta/translations/id.json b/homeassistant/components/lutron_caseta/translations/id.json index b14e9ad1c23..409cea59060 100644 --- a/homeassistant/components/lutron_caseta/translations/id.json +++ b/homeassistant/components/lutron_caseta/translations/id.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Lutron Cas\u00e9ta {name} ({host})", + "flow_title": "{name} ({host})", "step": { "import_failed": { "description": "Tidak dapat menyiapkan bridge (host: {host} ) yang diimpor dari configuration.yaml.", diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 07c5bfeaf89..d253c1b0349 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -109,7 +109,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="lyric_coordinator", update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=120), + update_interval=timedelta(seconds=300), ) hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index b5b0ffdeb3d..6f550813ad8 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -1,7 +1,10 @@ """Support for Honeywell Lyric sensor platform.""" +from __future__ import annotations + +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Callable, cast +from typing import cast from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation diff --git a/homeassistant/components/lyric/translations/fr.json b/homeassistant/components/lyric/translations/fr.json index 9eb02fc3811..25992620652 100644 --- a/homeassistant/components/lyric/translations/fr.json +++ b/homeassistant/components/lyric/translations/fr.json @@ -3,7 +3,7 @@ "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.", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { "default": "Authentification r\u00e9ussie" @@ -14,7 +14,7 @@ }, "reauth_confirm": { "description": "L'int\u00e9gration Lyric doit authentifier \u00e0 nouveau votre compte.", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" } } } diff --git a/homeassistant/components/lyric/translations/hu.json b/homeassistant/components/lyric/translations/hu.json index c6174673a90..7586310c8a7 100644 --- a/homeassistant/components/lyric/translations/hu.json +++ b/homeassistant/components/lyric/translations/hu.json @@ -2,7 +2,7 @@ "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\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "create_entry": { diff --git a/homeassistant/components/lyric/translations/id.json b/homeassistant/components/lyric/translations/id.json index 876fe2f8c39..f1057fc7cb2 100644 --- a/homeassistant/components/lyric/translations/id.json +++ b/homeassistant/components/lyric/translations/id.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", - "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi." + "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", + "reauth_successful": "Autentikasi ulang berhasil" }, "create_entry": { "default": "Berhasil diautentikasi" @@ -10,6 +11,9 @@ "step": { "pick_implementation": { "title": "Pilih Metode Autentikasi" + }, + "reauth_confirm": { + "title": "Autentikasi Ulang Integrasi" } } } diff --git a/homeassistant/components/mailgun/translations/hu.json b/homeassistant/components/mailgun/translations/hu.json index 14c2293734c..b40c4316bba 100644 --- a/homeassistant/components/mailgun/translations/hu.json +++ b/homeassistant/components/mailgun/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant programnak, be kell \u00e1ll\u00edtania a [Webhooks with Mailgun]({mailgun_url}) alkalmaz\u00e1st. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatiz\u00e1l\u00e1sokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant programnak, be kell \u00e1ll\u00edtania a [Webhooks with Mailgun]({mailgun_url}) alkalmaz\u00e1st. \n\nT\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/json \n\nL\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatizmusokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Mailgunt?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani a Mailgunt?", "title": "Mailgun Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 00c155615ee..d8b1ed088e3 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -12,6 +12,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, SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( @@ -26,6 +27,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, @@ -53,6 +55,7 @@ SUPPORTED_STATES = [ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED, ] @@ -132,6 +135,9 @@ PLATFORM_SCHEMA = vol.Schema( vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): _state_schema( STATE_ALARM_ARMED_NIGHT ), + vol.Optional(STATE_ALARM_ARMED_VACATION, default={}): _state_schema( + STATE_ALARM_ARMED_VACATION + ), vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, default={}): _state_schema( STATE_ALARM_ARMED_CUSTOM_BYPASS ), @@ -250,6 +256,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_TRIGGER | SUPPORT_ALARM_ARM_CUSTOM_BYPASS ) @@ -327,6 +334,15 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_NIGHT) + def alarm_arm_vacation(self, code=None): + """Send arm vacation command.""" + if self._code_arm_required and not self._validate_code( + code, STATE_ALARM_ARMED_VACATION + ): + return + + self._update_state(STATE_ALARM_ARMED_VACATION) + def alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command.""" if self._code_arm_required and not self._validate_code( @@ -396,7 +412,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if self.state == STATE_ALARM_PENDING or self.state == STATE_ALARM_ARMING: + if self.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING): return { ATTR_PREVIOUS_STATE: self._previous_state, ATTR_NEXT_STATE: self._state, @@ -414,10 +430,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): state = await self.async_get_last_state() if state: if ( - ( - state.state == STATE_ALARM_PENDING - or state.state == STATE_ALARM_ARMING - ) + state.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING) and hasattr(state, "attributes") and state.attributes[ATTR_PREVIOUS_STATE] ): diff --git a/homeassistant/components/mazda/translations/ca.json b/homeassistant/components/mazda/translations/ca.json index ef00713216a..17ef370b007 100644 --- a/homeassistant/components/mazda/translations/ca.json +++ b/homeassistant/components/mazda/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/mazda/translations/fr.json b/homeassistant/components/mazda/translations/fr.json index ff0ee33d368..1f6f442a3ae 100644 --- a/homeassistant/components/mazda/translations/fr.json +++ b/homeassistant/components/mazda/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9ja configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "account_locked": "Compte bloqu\u00e9. Veuillez r\u00e9essayer plus tard.", - "cannot_connect": "Echec de la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/mazda/translations/hu.json b/homeassistant/components/mazda/translations/hu.json index e6b80240184..c3f00040ea3 100644 --- a/homeassistant/components/mazda/translations/hu.json +++ b/homeassistant/components/mazda/translations/hu.json @@ -5,7 +5,7 @@ "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { - "account_locked": "Fi\u00f3k z\u00e1rolva. K\u00e9rlek, pr\u00f3b\u00e1ld \u00fajra k\u00e9s\u0151bb.", + "account_locked": "Fi\u00f3k z\u00e1rolva. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b0030786ed7..9ac350a5714 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -507,6 +507,7 @@ class MediaPlayerEntity(Entity): Must be implemented by integration. """ + # pylint: disable=no-self-use return None, None @property diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 532519616d2..9aa75ab935c 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -80,7 +83,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "turned_on": diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 98aeaf73be1..d4eeb9354c8 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, HVAC_MODE_COOL, @@ -29,7 +30,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform @@ -169,20 +170,25 @@ class AtaDeviceClimate(MelCloudClimate): return HVAC_MODE_OFF return ATA_HVAC_MODE_LOOKUP.get(mode) - async def async_set_hvac_mode(self, hvac_mode: str) -> None: - """Set new target hvac mode.""" + def _apply_set_hvac_mode(self, hvac_mode: str, set_dict: dict[str, Any]) -> None: + """Apply hvac mode changes to a dict used to call _device.set.""" if hvac_mode == HVAC_MODE_OFF: - await self._device.set({"power": False}) + set_dict["power"] = False return operation_mode = ATA_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) if operation_mode is None: raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") - props = {"operation_mode": operation_mode} + set_dict["operation_mode"] = operation_mode if self.hvac_mode == HVAC_MODE_OFF: - props["power"] = True - await self._device.set(props) + set_dict["power"] = True + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + set_dict = {} + self._apply_set_hvac_mode(hvac_mode, set_dict) + await self._device.set(set_dict) @property def hvac_modes(self) -> list[str]: @@ -203,9 +209,17 @@ class AtaDeviceClimate(MelCloudClimate): async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - await self._device.set( - {"target_temperature": kwargs.get("temperature", self.target_temperature)} - ) + set_dict = {} + if ATTR_HVAC_MODE in kwargs: + self._apply_set_hvac_mode( + kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict + ) + + if ATTR_TEMPERATURE in kwargs: + set_dict["target_temperature"] = kwargs.get(ATTR_TEMPERATURE) + + if set_dict: + await self._device.set(set_dict) @property def fan_mode(self) -> str | None: diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index e3c041727c0..48ee84382fa 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -60,7 +60,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.hass.helpers.aiohttp_client.async_get_clientsession(), ) except ClientResponseError as err: - if err.status == HTTP_UNAUTHORIZED or err.status == HTTP_FORBIDDEN: + if err.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN): return self.async_abort(reason="invalid_auth") return self.async_abort(reason="cannot_connect") except (asyncio.TimeoutError, ClientError): diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 4aff46a22b6..5c8f7d7ca2c 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==2.5.3"], + "requirements": ["pymelcloud==2.5.4"], "codeowners": ["@vilppuvuorinen"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 608c3547724..19be1ea172d 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,8 +1,9 @@ """Support for MelCloud device sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW from pymelcloud.atw_device import Zone diff --git a/homeassistant/components/melcloud/translations/fr.json b/homeassistant/components/melcloud/translations/fr.json index 1ee577e0bfa..f7f40c68bc5 100644 --- a/homeassistant/components/melcloud/translations/fr.json +++ b/homeassistant/components/melcloud/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'int\u00e9gration MELCloud est d\u00e9j\u00e0 configur\u00e9e pour cet e-mail. Le jeton d'acc\u00e8s a \u00e9t\u00e9 actualis\u00e9." }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 159083ecd23..28953a47213 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -1,4 +1,5 @@ """Support for the Meraki CMX location service.""" +from http import HTTPStatus import json import logging @@ -9,7 +10,6 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -56,21 +56,23 @@ class MerakiView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data)) if not data.get("secret", False): _LOGGER.error("The secret is invalid") - return self.json_message("No secret", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message("No secret", HTTPStatus.UNPROCESSABLE_ENTITY) if data["secret"] != self.secret: _LOGGER.error("Invalid Secret received from Meraki") - return self.json_message("Invalid secret", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message("Invalid secret", HTTPStatus.UNPROCESSABLE_ENTITY) if data["version"] != VERSION: _LOGGER.error("Invalid API version: %s", data["version"]) - return self.json_message("Invalid version", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message("Invalid version", HTTPStatus.UNPROCESSABLE_ENTITY) _LOGGER.debug("Valid Secret") if data["type"] not in ("DevicesSeen", "BluetoothDevicesSeen"): _LOGGER.error("Unknown Device %s", data["type"]) - return self.json_message("Invalid device type", HTTP_UNPROCESSABLE_ENTITY) + return self.json_message( + "Invalid device type", HTTPStatus.UNPROCESSABLE_ENTITY + ) _LOGGER.debug("Processing %s", data["type"]) if not data["data"]["observations"]: _LOGGER.debug("No observations found") diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index a2e9eeb2799..09b48bc1b3e 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,5 +1,9 @@ """Meteo-France component constants.""" +from __future__ import annotations +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -47,127 +51,131 @@ FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" ATTR_NEXT_RAIN_DT_REF = "forecast_time_ref" -ENTITY_NAME = "name" -ENTITY_UNIT = "unit" -ENTITY_ICON = "icon" -ENTITY_DEVICE_CLASS = "device_class" -ENTITY_ENABLE = "enable" -ENTITY_API_DATA_PATH = "data_path" -SENSOR_TYPES = { - "pressure": { - ENTITY_NAME: "Pressure", - ENTITY_UNIT: PRESSURE_HPA, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:sea_level", - }, - "rain_chance": { - ENTITY_NAME: "Rain chance", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:weather-rainy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "probability_forecast:rain:3h", - }, - "snow_chance": { - ENTITY_NAME: "Snow chance", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:weather-snowy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "probability_forecast:snow:3h", - }, - "freeze_chance": { - ENTITY_NAME: "Freeze chance", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:snowflake", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "probability_forecast:freezing", - }, - "wind_gust": { - ENTITY_NAME: "Wind gust", - ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, - ENTITY_ICON: "mdi:weather-windy-variant", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:wind:gust", - }, - "wind_speed": { - ENTITY_NAME: "Wind speed", - ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, - ENTITY_ICON: "mdi:weather-windy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:wind:speed", - }, - "next_rain": { - ENTITY_NAME: "Next rain", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: None, - }, - "temperature": { - ENTITY_NAME: "Temperature", - ENTITY_UNIT: TEMP_CELSIUS, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:T:value", - }, - "uv": { - ENTITY_NAME: "UV", - ENTITY_UNIT: UV_INDEX, - ENTITY_ICON: "mdi:sunglasses", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "today_forecast:uv", - }, - "weather_alert": { - ENTITY_NAME: "Weather alert", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:weather-cloudy-alert", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: None, - }, - "precipitation": { - ENTITY_NAME: "Daily precipitation", - ENTITY_UNIT: LENGTH_MILLIMETERS, - ENTITY_ICON: "mdi:cup-water", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "today_forecast:precipitation:24h", - }, - "cloud": { - ENTITY_NAME: "Cloud cover", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:weather-partly-cloudy", - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: True, - ENTITY_API_DATA_PATH: "current_forecast:clouds", - }, - "original_condition": { - ENTITY_NAME: "Original condition", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "current_forecast:weather:desc", - }, - "daily_original_condition": { - ENTITY_NAME: "Daily original condition", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_DEVICE_CLASS: None, - ENTITY_ENABLE: False, - ENTITY_API_DATA_PATH: "today_forecast:weather12H:desc", - }, -} +@dataclass +class MeteoFranceRequiredKeysMixin: + """Mixin for required keys.""" + + data_path: str + + +@dataclass +class MeteoFranceSensorEntityDescription( + SensorEntityDescription, MeteoFranceRequiredKeysMixin +): + """Describes Meteo-France sensor entity.""" + + +SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + entity_registry_enabled_default=False, + data_path="current_forecast:sea_level", + ), + MeteoFranceSensorEntityDescription( + key="wind_gust", + name="Wind gust", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy-variant", + entity_registry_enabled_default=False, + data_path="current_forecast:wind:gust", + ), + MeteoFranceSensorEntityDescription( + key="wind_speed", + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + entity_registry_enabled_default=False, + data_path="current_forecast:wind:speed", + ), + MeteoFranceSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + data_path="current_forecast:T:value", + ), + MeteoFranceSensorEntityDescription( + key="uv", + name="UV", + native_unit_of_measurement=UV_INDEX, + icon="mdi:sunglasses", + data_path="today_forecast:uv", + ), + MeteoFranceSensorEntityDescription( + key="precipitation", + name="Daily precipitation", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:cup-water", + data_path="today_forecast:precipitation:24h", + ), + MeteoFranceSensorEntityDescription( + key="cloud", + name="Cloud cover", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + data_path="current_forecast:clouds", + ), + MeteoFranceSensorEntityDescription( + key="original_condition", + name="Original condition", + entity_registry_enabled_default=False, + data_path="current_forecast:weather:desc", + ), + MeteoFranceSensorEntityDescription( + key="daily_original_condition", + name="Daily original condition", + entity_registry_enabled_default=False, + data_path="today_forecast:weather12H:desc", + ), +) + +SENSOR_TYPES_RAIN: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="next_rain", + name="Next rain", + device_class=DEVICE_CLASS_TIMESTAMP, + data_path="", + ), +) + +SENSOR_TYPES_ALERT: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="weather_alert", + name="Weather alert", + icon="mdi:weather-cloudy-alert", + data_path="", + ), +) + +SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = ( + MeteoFranceSensorEntityDescription( + key="rain_chance", + name="Rain chance", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-rainy", + data_path="probability_forecast:rain:3h", + ), + MeteoFranceSensorEntityDescription( + key="snow_chance", + name="Snow chance", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-snowy", + data_path="probability_forecast:snow:3h", + ), + MeteoFranceSensorEntityDescription( + key="freeze_chance", + name="Freeze chance", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:snowflake", + data_path="probability_forecast:freezing", + ), +) + CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT: ["Nuit Claire", "Nuit claire"], diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index df006c78194..1a5b3c4a33a 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,6 +1,4 @@ """Support for Meteo-France raining forecast sensor.""" -import logging - from meteofrance_api.helpers import ( get_warning_text_status_from_indice_color, readeable_phenomenoms_dict, @@ -24,19 +22,15 @@ from .const import ( COORDINATOR_FORECAST, COORDINATOR_RAIN, DOMAIN, - ENTITY_API_DATA_PATH, - ENTITY_DEVICE_CLASS, - ENTITY_ENABLE, - ENTITY_ICON, - ENTITY_NAME, - ENTITY_UNIT, MANUFACTURER, MODEL, SENSOR_TYPES, + SENSOR_TYPES_ALERT, + SENSOR_TYPES_PROBABILITY, + SENSOR_TYPES_RAIN, + MeteoFranceSensorEntityDescription, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities @@ -46,56 +40,56 @@ async def async_setup_entry( coordinator_rain = hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] coordinator_alert = hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] - entities = [] - for sensor_type in SENSOR_TYPES: - if sensor_type == "next_rain": - if coordinator_rain: - entities.append(MeteoFranceRainSensor(sensor_type, coordinator_rain)) + entities = [ + MeteoFranceSensor(coordinator_forecast, description) + for description in SENSOR_TYPES + ] + # Add rain forecast entity only if location support this feature + if coordinator_rain: + entities.extend( + [ + MeteoFranceRainSensor(coordinator_rain, description) + for description in SENSOR_TYPES_RAIN + ] + ) + # Add weather alert entity only if location support this feature + if coordinator_alert: + entities.extend( + [ + MeteoFranceAlertSensor(coordinator_alert, description) + for description in SENSOR_TYPES_ALERT + ] + ) + # Add weather probability entities only if location support this feature + if coordinator_forecast.data.probability_forecast: + entities.extend( + [ + MeteoFranceSensor(coordinator_forecast, description) + for description in SENSOR_TYPES_PROBABILITY + ] + ) - elif sensor_type == "weather_alert": - if coordinator_alert: - entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) - - elif sensor_type in ("rain_chance", "freeze_chance", "snow_chance"): - if coordinator_forecast.data.probability_forecast: - entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) - else: - _LOGGER.warning( - "Sensor %s skipped for %s as data is missing in the API", - sensor_type, - coordinator_forecast.data.position["name"], - ) - - else: - entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) - - async_add_entities( - entities, - False, - ) + async_add_entities(entities, False) class MeteoFranceSensor(CoordinatorEntity, SensorEntity): """Representation of a Meteo-France sensor.""" - def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator) -> None: + entity_description: MeteoFranceSensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: MeteoFranceSensorEntityDescription, + ) -> None: """Initialize the Meteo-France sensor.""" super().__init__(coordinator) - self._type = sensor_type - if hasattr(self.coordinator.data, "position"): - city_name = self.coordinator.data.position["name"] - self._name = f"{city_name} {SENSOR_TYPES[self._type][ENTITY_NAME]}" - self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}_{self._type}" - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name.""" - return self._name + self.entity_description = description + if hasattr(coordinator.data, "position"): + city_name = coordinator.data.position["name"] + self._attr_name = f"{city_name} {description.name}" + self._attr_unique_id = f"{coordinator.data.position['lat']},{coordinator.data.position['lon']}_{description.key}" + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} @property def device_info(self): @@ -111,7 +105,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the state.""" - path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") + path = self.entity_description.data_path.split(":") data = getattr(self.coordinator.data, path[0]) # Specific case for probability forecast @@ -129,36 +123,11 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): else: value = data[path[1]] - if self._type in ("wind_speed", "wind_gust"): + if self.entity_description.key in ("wind_speed", "wind_gust"): # convert API wind speed from m/s to km/h value = round(value * 3.6) return value - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][ENTITY_UNIT] - - @property - def icon(self): - """Return the icon.""" - return SENSOR_TYPES[self._type][ENTITY_ICON] - - @property - def device_class(self): - """Return the device class.""" - return SENSOR_TYPES[self._type][ENTITY_DEVICE_CLASS] - - @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][ENTITY_ENABLE] - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - class MeteoFranceRainSensor(MeteoFranceSensor): """Representation of a Meteo-France rain sensor.""" @@ -194,12 +163,16 @@ class MeteoFranceRainSensor(MeteoFranceSensor): class MeteoFranceAlertSensor(MeteoFranceSensor): """Representation of a Meteo-France alert sensor.""" - def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: MeteoFranceSensorEntityDescription, + ) -> None: """Initialize the Meteo-France sensor.""" - super().__init__(sensor_type, coordinator) + super().__init__(coordinator, description) dept_code = self.coordinator.data.domain_id - self._name = f"{dept_code} {SENSOR_TYPES[self._type][ENTITY_NAME]}" - self._unique_id = self._name + self._attr_name = f"{dept_code} {description.name}" + self._attr_unique_id = self._attr_name @property def native_value(self): diff --git a/homeassistant/components/meteo_france/translations/fr.json b/homeassistant/components/meteo_france/translations/fr.json index 8e321092d63..1121dd02b17 100644 --- a/homeassistant/components/meteo_france/translations/fr.json +++ b/homeassistant/components/meteo_france/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Ville d\u00e9j\u00e0 configur\u00e9e", - "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" }, "error": { "empty": "Aucun r\u00e9sultat dans la recherche par ville: veuillez v\u00e9rifier le champ de la ville" diff --git a/homeassistant/components/meteo_france/translations/hu.json b/homeassistant/components/meteo_france/translations/hu.json index 112f70b6ea6..8034f6d0586 100644 --- a/homeassistant/components/meteo_france/translations/hu.json +++ b/homeassistant/components/meteo_france/translations/hu.json @@ -12,7 +12,7 @@ "data": { "city": "V\u00e1ros" }, - "description": "V\u00e1laszd ki a v\u00e1rost a list\u00e1b\u00f3l", + "description": "V\u00e1lassza ki a v\u00e1rost a list\u00e1b\u00f3l", "title": "M\u00e9t\u00e9o-France" }, "user": { diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 6d237c696f6..ce0fa97ecb9 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -85,10 +86,14 @@ class MeteoAlertBinarySensor(BinarySensorEntity): def update(self): """Update device state.""" + self._attributes = {} + self._state = False + alert = self._api.get_alert() if alert: - self._attributes = alert - self._state = True - else: - self._attributes = {} - self._state = False + expiration_date = dt_util.parse_datetime(alert["expires"]) + now = dt_util.utcnow() + + if expiration_date > now: + self._attributes = alert + self._state = True diff --git a/homeassistant/components/meteoclimatic/const.py b/homeassistant/components/meteoclimatic/const.py index cd4be5821ea..f4e51a6fb10 100644 --- a/homeassistant/components/meteoclimatic/const.py +++ b/homeassistant/components/meteoclimatic/const.py @@ -1,9 +1,11 @@ """Meteoclimatic component constants.""" +from __future__ import annotations from datetime import timedelta from meteoclimatic import Condition +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -45,77 +47,86 @@ CONF_STATION_CODE = "station_code" DEFAULT_WEATHER_CARD = True -SENSOR_TYPE_NAME = "name" -SENSOR_TYPE_UNIT = "unit" -SENSOR_TYPE_ICON = "icon" -SENSOR_TYPE_CLASS = "device_class" -SENSOR_TYPES = { - "temp_current": { - SENSOR_TYPE_NAME: "Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - "temp_max": { - SENSOR_TYPE_NAME: "Daily Max Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - "temp_min": { - SENSOR_TYPE_NAME: "Daily Min Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - "humidity_current": { - SENSOR_TYPE_NAME: "Humidity", - SENSOR_TYPE_UNIT: PERCENTAGE, - SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - "humidity_max": { - SENSOR_TYPE_NAME: "Daily Max Humidity", - SENSOR_TYPE_UNIT: PERCENTAGE, - SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - "humidity_min": { - SENSOR_TYPE_NAME: "Daily Min Humidity", - SENSOR_TYPE_UNIT: PERCENTAGE, - SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY, - }, - "pressure_current": { - SENSOR_TYPE_NAME: "Pressure", - SENSOR_TYPE_UNIT: PRESSURE_HPA, - SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE, - }, - "pressure_max": { - SENSOR_TYPE_NAME: "Daily Max Pressure", - SENSOR_TYPE_UNIT: PRESSURE_HPA, - SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE, - }, - "pressure_min": { - SENSOR_TYPE_NAME: "Daily Min Pressure", - SENSOR_TYPE_UNIT: PRESSURE_HPA, - SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE, - }, - "wind_current": { - SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - }, - "wind_max": { - SENSOR_TYPE_NAME: "Daily Max Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - }, - "wind_bearing": { - SENSOR_TYPE_NAME: "Wind Bearing", - SENSOR_TYPE_UNIT: DEGREE, - SENSOR_TYPE_ICON: "mdi:weather-windy", - }, - "rain": { - SENSOR_TYPE_NAME: "Daily Precipitation", - SENSOR_TYPE_UNIT: LENGTH_MILLIMETERS, - SENSOR_TYPE_ICON: "mdi:cup-water", - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temp_current", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temp_max", + name="Daily Max Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temp_min", + name="Daily Min Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity_current", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="humidity_max", + name="Daily Max Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="humidity_min", + name="Daily Min Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="pressure_current", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key="pressure_max", + name="Daily Max Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key="pressure_min", + name="Daily Min Pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key="wind_current", + name="Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + device_class="mdi:weather-windy", + ), + SensorEntityDescription( + key="wind_max", + name="Daily Max Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + device_class="mdi:weather-windy", + ), + SensorEntityDescription( + key="wind_bearing", + name="Wind Bearing", + native_unit_of_measurement=DEGREE, + device_class="mdi:weather-windy", + ), + SensorEntityDescription( + key="rain", + name="Daily Precipitation", + native_unit_of_measurement=LENGTH_MILLIMETERS, + device_class="mdi:cup-water", + ), +) CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT: [Condition.moon, Condition.hazemoon], diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index b5a07ad06e6..d3ecb44ce70 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -1,7 +1,7 @@ """Support for Meteoclimatic sensor.""" import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant @@ -10,17 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ( - ATTRIBUTION, - DOMAIN, - MANUFACTURER, - MODEL, - SENSOR_TYPE_CLASS, - SENSOR_TYPE_ICON, - SENSOR_TYPE_NAME, - SENSOR_TYPE_UNIT, - SENSOR_TYPES, -) +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL, SENSOR_TYPES _LOGGER = logging.getLogger(__name__) @@ -32,7 +22,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [MeteoclimaticSensor(sensor_type, coordinator) for sensor_type in SENSOR_TYPES], + [MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES], False, ) @@ -40,20 +30,17 @@ async def async_setup_entry( class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): """Representation of a Meteoclimatic sensor.""" - def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator) -> None: + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__( + self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription + ) -> None: """Initialize the Meteoclimatic sensor.""" super().__init__(coordinator) - self._type = sensor_type + self.entity_description = description station = self.coordinator.data["station"] - self._attr_device_class = SENSOR_TYPES[sensor_type].get(SENSOR_TYPE_CLASS) - self._attr_icon = SENSOR_TYPES[sensor_type].get(SENSOR_TYPE_ICON) - self._attr_name = ( - f"{station.name} {SENSOR_TYPES[sensor_type][SENSOR_TYPE_NAME]}" - ) - self._attr_unique_id = f"{station.code}_{sensor_type}" - self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type].get( - SENSOR_TYPE_UNIT - ) + self._attr_name = f"{station.name} {description.name}" + self._attr_unique_id = f"{station.code}_{description.key}" @property def device_info(self): @@ -70,12 +57,7 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): def native_value(self): """Return the state of the sensor.""" return ( - getattr(self.coordinator.data["weather"], self._type) + getattr(self.coordinator.data["weather"], self.entity_description.key) if self.coordinator.data else None ) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/meteoclimatic/translations/es.json b/homeassistant/components/meteoclimatic/translations/es.json index fd9a38db804..ab84e6604e3 100644 --- a/homeassistant/components/meteoclimatic/translations/es.json +++ b/homeassistant/components/meteoclimatic/translations/es.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "unknown": "Error inesperado" + }, + "error": { + "not_found": "No se encontraron dispositivos en la red" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/meteoclimatic/translations/id.json b/homeassistant/components/meteoclimatic/translations/id.json new file mode 100644 index 00000000000..81dddee653f --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/id.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "not_found": "Tidak ada perangkat yang ditemukan di jaringan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/translations/fr.json b/homeassistant/components/metoffice/translations/fr.json index a046a71fe95..d5219342c00 100644 --- a/homeassistant/components/metoffice/translations/fr.json +++ b/homeassistant/components/metoffice/translations/fr.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "api_key": "Cl\u00e9 API Met Office DataPoint", + "api_key": "Cl\u00e9 d'API", "latitude": "Latitude", "longitude": "Longitude" }, diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index 0e9abe5c757..1b13b4d0537 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -81,7 +81,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="conductivity", name="Conductivity", native_unit_of_measurement=CONDUCTIVITY, - icon="mdi:flash-circle", + icon="mdi:lightning-bolt-circle", ), SensorEntityDescription( key="battery", diff --git a/homeassistant/components/mikrotik/translations/fr.json b/homeassistant/components/mikrotik/translations/fr.json index 1d328a843cc..5632ab5b8dd 100644 --- a/homeassistant/components/mikrotik/translations/fr.json +++ b/homeassistant/components/mikrotik/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Mikrotik est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de la connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "name_exists": "Le nom existe" }, "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom", "password": "Mot de passe", "port": "Port", diff --git a/homeassistant/components/mikrotik/translations/he.json b/homeassistant/components/mikrotik/translations/he.json index 6f8286290d4..bef2f812e0f 100644 --- a/homeassistant/components/mikrotik/translations/he.json +++ b/homeassistant/components/mikrotik/translations/he.json @@ -18,5 +18,16 @@ } } } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "\u05d4\u05e4\u05d9\u05db\u05ea \u05e4\u05d9\u05e0\u05d2 \u05e9\u05dc ARP \u05dc\u05d6\u05de\u05d9\u05df", + "detection_time": "\u05e9\u05e7\u05d5\u05dc \u05de\u05e8\u05d5\u05d5\u05d7 \u05d6\u05de\u05df \u05d1\u05d9\u05ea\u05d9", + "force_dhcp": "\u05db\u05e4\u05d9\u05d9\u05ea \u05e1\u05e8\u05d9\u05e7\u05d4 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea DHCP" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/mikrotik/translations/hu.json b/homeassistant/components/mikrotik/translations/hu.json index 248884f9687..3e5281fc06a 100644 --- a/homeassistant/components/mikrotik/translations/hu.json +++ b/homeassistant/components/mikrotik/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/mikrotik/translations/ru.json b/homeassistant/components/mikrotik/translations/ru.json index 06e9d647545..015d2061c76 100644 --- a/homeassistant/components/mikrotik/translations/ru.json +++ b/homeassistant/components/mikrotik/translations/ru.json @@ -27,7 +27,7 @@ "device_tracker": { "data": { "arp_ping": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c ARP-\u043f\u0438\u043d\u0433", - "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "detection_time": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", "force_dhcp": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c DHCP" } } diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 33a7c35c169..bf332487a88 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -2,7 +2,7 @@ "domain": "mill", "name": "Mill", "documentation": "https://www.home-assistant.io/integrations/mill", - "requirements": ["millheater==0.5.2"], + "requirements": ["millheater==0.6.0"], "codeowners": ["@danielhiversen"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/mill/translations/ca.json b/homeassistant/components/mill/translations/ca.json index 13ce41cec91..309e5ccc41c 100644 --- a/homeassistant/components/mill/translations/ca.json +++ b/homeassistant/components/mill/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/minecraft_server/translations/fr.json b/homeassistant/components/minecraft_server/translations/fr.json index dde686376cc..48bf06592dd 100644 --- a/homeassistant/components/minecraft_server/translations/fr.json +++ b/homeassistant/components/minecraft_server/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion au serveur. Veuillez v\u00e9rifier l'h\u00f4te et le port et r\u00e9essayer. Assurez-vous \u00e9galement que vous ex\u00e9cutez au moins Minecraft version 1.7 sur votre serveur.", @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom" }, "description": "Configurez votre instance Minecraft Server pour permettre la surveillance.", diff --git a/homeassistant/components/minecraft_server/translations/hu.json b/homeassistant/components/minecraft_server/translations/hu.json index ef3c228d2d5..02c2a06d8ab 100644 --- a/homeassistant/components/minecraft_server/translations/hu.json +++ b/homeassistant/components/minecraft_server/translations/hu.json @@ -4,14 +4,14 @@ "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Nem siker\u00fclt csatlakozni a szerverhez. K\u00e9rj\u00fck, ellen\u0151rizze a gazdag\u00e9pet \u00e9s a portot, majd pr\u00f3b\u00e1lkozzon \u00fajra. Gondoskodjon arr\u00f3l, hogy a szerveren legal\u00e1bb a Minecraft 1.7-es verzi\u00f3j\u00e1t futtassa.", + "cannot_connect": "Nem siker\u00fclt csatlakozni a szerverhez. K\u00e9rj\u00fck, ellen\u0151rizze a c\u00edmet \u00e9s a portot, majd pr\u00f3b\u00e1lkozzon \u00fajra. Gondoskodjon arr\u00f3l, hogy a szerveren legal\u00e1bb a Minecraft 1.7-es verzi\u00f3j\u00e1t futtassa.", "invalid_ip": "Az IP -c\u00edm \u00e9rv\u00e9nytelen (a MAC -c\u00edmet nem siker\u00fclt meghat\u00e1rozni). K\u00e9rj\u00fck, jav\u00edtsa ki, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", "invalid_port": "A portnak 1024 \u00e9s 65535 k\u00f6z\u00f6tt kell lennie. K\u00e9rj\u00fck, jav\u00edtsa ki, \u00e9s pr\u00f3b\u00e1lja \u00fajra." }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "\u00c1ll\u00edtsa be a Minecraft Server p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a megfigyel\u00e9st.", diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 1fc5be2a890..73775f23e6d 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,25 +1,23 @@ """Integrates Native Apps to Home Assistant.""" from contextlib import suppress -import voluptuous as vol - -from homeassistant.components import cloud, notify as hass_notify, websocket_api +from homeassistant.components import cloud, notify as hass_notify from homeassistant.components.webhook import ( async_register as webhook_register, async_unregister as webhook_unregister, ) from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, discovery from homeassistant.helpers.typing import ConfigType +from . import websocket_api from .const import ( ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, CONF_CLOUDHOOK_URL, - CONF_USER_ID, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, @@ -66,7 +64,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: discovery.async_load_platform(hass, "notify", DOMAIN, {}, config) ) - websocket_api.async_register_command(hass, handle_push_notification_channel) + websocket_api.async_setup_commands(hass) return True @@ -127,52 +125,3 @@ async def async_remove_entry(hass, entry): if CONF_CLOUDHOOK_URL in entry.data: with suppress(cloud.CloudNotAvailable): await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) - - -@callback -@websocket_api.websocket_command( - { - vol.Required("type"): "mobile_app/push_notification_channel", - vol.Required("webhook_id"): str, - } -) -def handle_push_notification_channel(hass, connection, msg): - """Set up a direct push notification channel.""" - webhook_id = msg["webhook_id"] - - # Validate that the webhook ID is registered to the user of the websocket connection - config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES].get(webhook_id) - - if config_entry is None: - connection.send_error( - msg["id"], websocket_api.ERR_NOT_FOUND, "Webhook ID not found" - ) - return - - if config_entry.data[CONF_USER_ID] != connection.user.id: - connection.send_error( - msg["id"], - websocket_api.ERR_UNAUTHORIZED, - "User not linked to this webhook ID", - ) - return - - registered_channels = hass.data[DOMAIN][DATA_PUSH_CHANNEL] - - if webhook_id in registered_channels: - registered_channels.pop(webhook_id) - - @callback - def forward_push_notification(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.messages.event_message(msg["id"], data)) - - @callback - def unsub(): - # pylint: disable=comparison-with-callable - if registered_channels.get(webhook_id) == forward_push_notification: - registered_channels.pop(webhook_id) - - registered_channels[webhook_id] = forward_push_notification - connection.subscriptions[msg["id"]] = unsub - connection.send_result(msg["id"]) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 9902e1d93d7..2325a75e630 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -1,9 +1,9 @@ """Helpers for mobile_app.""" from __future__ import annotations +from collections.abc import Callable import json import logging -from typing import Callable from aiohttp.web import Response, json_response from nacl.encoding import Base64Encoder diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 63bf13bad5e..05b370c711d 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import suppress +from http import HTTPStatus import secrets from aiohttp.web import Request, Response @@ -11,7 +12,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID, HTTP_CREATED +from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify @@ -109,5 +110,5 @@ class RegistrationsView(HomeAssistantView): CONF_SECRET: data.get(CONF_SECRET), CONF_WEBHOOK_ID: data[CONF_WEBHOOK_ID], }, - status_code=HTTP_CREATED, + status_code=HTTPStatus.CREATED, ) diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index a59f9bf28cf..253ff16a34e 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.4.0", "emoji==1.2.0"], + "requirements": ["PyNaCl==1.4.0", "emoji==1.5.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 c98fdeb9999..025880d8107 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -1,5 +1,6 @@ """Support for mobile_app push notifications.""" import asyncio +from functools import partial import logging import aiohttp @@ -124,61 +125,65 @@ class MobileAppNotificationService(BaseNotificationService): for target in targets: if target in local_push_channels: - local_push_channels[target](data) + local_push_channels[target].async_send_notification( + data, partial(self._async_send_remote_message_target, target) + ) continue - entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] - entry_data = entry.data + await self._async_send_remote_message_target(target, data) - app_data = entry_data[ATTR_APP_DATA] - push_token = app_data[ATTR_PUSH_TOKEN] - push_url = app_data[ATTR_PUSH_URL] + async def _async_send_remote_message_target(self, target, data): + """Send a message to a target.""" + entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] + entry_data = entry.data - target_data = dict(data) - target_data[ATTR_PUSH_TOKEN] = push_token + app_data = entry_data[ATTR_APP_DATA] + push_token = app_data[ATTR_PUSH_TOKEN] + push_url = app_data[ATTR_PUSH_URL] - reg_info = { - ATTR_APP_ID: entry_data[ATTR_APP_ID], - ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION], - } - if ATTR_OS_VERSION in entry_data: - reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION] + target_data = dict(data) + target_data[ATTR_PUSH_TOKEN] = push_token - target_data["registration_info"] = reg_info + reg_info = { + ATTR_APP_ID: entry_data[ATTR_APP_ID], + ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION], + } + if ATTR_OS_VERSION in entry_data: + reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION] - try: - with async_timeout.timeout(10): - response = await async_get_clientsession(self._hass).post( - push_url, json=target_data - ) - result = await response.json() + target_data["registration_info"] = reg_info - if response.status in (HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED): - log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) - continue - - fallback_error = result.get("errorMessage", "Unknown error") - fallback_message = ( - f"Internal server error, please try again later: {fallback_error}" + try: + with async_timeout.timeout(10): + response = await async_get_clientsession(self._hass).post( + push_url, json=target_data ) - message = result.get("message", fallback_message) + result = await response.json() - if "message" in result: - if message[-1] not in [".", "?", "!"]: - message += "." - message += ( - " This message is generated externally to Home Assistant." - ) + if response.status in (HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED): + log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) + return - if response.status == HTTP_TOO_MANY_REQUESTS: - _LOGGER.warning(message) - log_rate_limits( - self.hass, entry_data[ATTR_DEVICE_NAME], result, logging.WARNING - ) - else: - _LOGGER.error(message) + fallback_error = result.get("errorMessage", "Unknown error") + fallback_message = ( + f"Internal server error, please try again later: {fallback_error}" + ) + message = result.get("message", fallback_message) - except asyncio.TimeoutError: - _LOGGER.error("Timeout sending notification to %s", push_url) - except aiohttp.ClientError as err: - _LOGGER.error("Error sending notification to %s: %r", push_url, err) + if "message" in result: + if message[-1] not in [".", "?", "!"]: + message += "." + message += " This message is generated externally to Home Assistant." + + if response.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning(message) + log_rate_limits( + self.hass, entry_data[ATTR_DEVICE_NAME], result, logging.WARNING + ) + else: + _LOGGER.error(message) + + except asyncio.TimeoutError: + _LOGGER.error("Timeout sending notification to %s", push_url) + except aiohttp.ClientError as err: + _LOGGER.error("Error sending notification to %s: %r", push_url, err) diff --git a/homeassistant/components/mobile_app/push_notification.py b/homeassistant/components/mobile_app/push_notification.py new file mode 100644 index 00000000000..f3852895d32 --- /dev/null +++ b/homeassistant/components/mobile_app/push_notification.py @@ -0,0 +1,92 @@ +"""Push notification handling.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later +from homeassistant.util.uuid import random_uuid_hex + +PUSH_CONFIRM_TIMEOUT = 10 # seconds + + +class PushChannel: + """Class that represents a push channel.""" + + def __init__( + self, + hass: HomeAssistant, + webhook_id: str, + support_confirm: bool, + send_message: Callable[[dict], None], + on_teardown: Callable[[], None], + ) -> None: + """Initialize a local push channel.""" + self.hass = hass + self.webhook_id = webhook_id + self.support_confirm = support_confirm + self._send_message = send_message + self.on_teardown = on_teardown + self.pending_confirms = {} + + @callback + def async_send_notification(self, data, fallback_send): + """Send a push notification.""" + if not self.support_confirm: + self._send_message(data) + return + + confirm_id = random_uuid_hex() + data["hass_confirm_id"] = confirm_id + + async def handle_push_failed(_=None): + """Handle a failed local push notification.""" + # Remove this handler from the pending dict + # If it didn't exist we hit a race condition between call_later and another + # push failing and tearing down the connection. + if self.pending_confirms.pop(confirm_id, None) is None: + return + + # Drop local channel if it's still open + if self.on_teardown is not None: + await self.async_teardown() + + await fallback_send(data) + + self.pending_confirms[confirm_id] = { + "unsub_scheduled_push_failed": async_call_later( + self.hass, PUSH_CONFIRM_TIMEOUT, handle_push_failed + ), + "handle_push_failed": handle_push_failed, + } + self._send_message(data) + + @callback + def async_confirm_notification(self, confirm_id) -> bool: + """Confirm a push notification. + + Returns if confirmation successful. + """ + if confirm_id not in self.pending_confirms: + return False + + self.pending_confirms.pop(confirm_id)["unsub_scheduled_push_failed"]() + return True + + async def async_teardown(self): + """Tear down this channel.""" + # Tear down is in progress + if self.on_teardown is None: + return + + self.on_teardown() + self.on_teardown = None + + cancel_pending_local_tasks = [ + actions["handle_push_failed"]() + for actions in self.pending_confirms.values() + ] + + if cancel_pending_local_tasks: + await asyncio.gather(*cancel_pending_local_tasks) diff --git a/homeassistant/components/mobile_app/translations/hu.json b/homeassistant/components/mobile_app/translations/hu.json index 90690e2545b..a92f84958be 100644 --- a/homeassistant/components/mobile_app/translations/hu.json +++ b/homeassistant/components/mobile_app/translations/hu.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "install_app": "Nyisd meg a mobil alkalmaz\u00e1st a Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizd [a le\u00edr\u00e1st]({apps_url})." + "install_app": "Nyissa meg a mobil alkalmaz\u00e1st Home Assistanttal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizze [a le\u00edr\u00e1st]({apps_url})." }, "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a mobil alkalmaz\u00e1s komponenst?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a mobil alkalmaz\u00e1s komponenst?" } } }, diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py new file mode 100644 index 00000000000..4b0863d77af --- /dev/null +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -0,0 +1,121 @@ +"""Mobile app websocket API.""" +from __future__ import annotations + +from functools import wraps + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import callback + +from .const import CONF_USER_ID, DATA_CONFIG_ENTRIES, DATA_PUSH_CHANNEL, DOMAIN +from .push_notification import PushChannel + + +@callback +def async_setup_commands(hass): + """Set up the mobile app websocket API.""" + websocket_api.async_register_command(hass, handle_push_notification_channel) + websocket_api.async_register_command(hass, handle_push_notification_confirm) + + +def _ensure_webhook_access(func): + """Decorate WS function to ensure user owns the webhook ID.""" + + @callback + @wraps(func) + def with_webhook_access(hass, connection, msg): + # Validate that the webhook ID is registered to the user of the websocket connection + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES].get(msg["webhook_id"]) + + if config_entry is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Webhook ID not found" + ) + return + + if config_entry.data[CONF_USER_ID] != connection.user.id: + connection.send_error( + msg["id"], + websocket_api.ERR_UNAUTHORIZED, + "User not linked to this webhook ID", + ) + return + + func(hass, connection, msg) + + return with_webhook_access + + +@callback +@_ensure_webhook_access +@websocket_api.websocket_command( + { + vol.Required("type"): "mobile_app/push_notification_confirm", + vol.Required("webhook_id"): str, + vol.Required("confirm_id"): str, + } +) +def handle_push_notification_confirm(hass, connection, msg): + """Confirm receipt of a push notification.""" + channel: PushChannel | None = hass.data[DOMAIN][DATA_PUSH_CHANNEL].get( + msg["webhook_id"] + ) + if channel is None: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_FOUND, + "Push notification channel not found", + ) + return + + if channel.async_confirm_notification(msg["confirm_id"]): + connection.send_result(msg["id"]) + else: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_FOUND, + "Push notification channel not found", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "mobile_app/push_notification_channel", + vol.Required("webhook_id"): str, + vol.Optional("support_confirm", default=False): bool, + } +) +@_ensure_webhook_access +@websocket_api.async_response +async def handle_push_notification_channel(hass, connection, msg): + """Set up a direct push notification channel.""" + webhook_id = msg["webhook_id"] + registered_channels: dict[str, PushChannel] = hass.data[DOMAIN][DATA_PUSH_CHANNEL] + + if webhook_id in registered_channels: + await registered_channels[webhook_id].async_teardown() + + @callback + def on_channel_teardown(): + """Handle teardown.""" + if registered_channels.get(webhook_id) == channel: + registered_channels.pop(webhook_id) + + # Remove subscription from connection if still exists + connection.subscriptions.pop(msg["id"], None) + + channel = registered_channels[webhook_id] = PushChannel( + hass, + webhook_id, + msg["support_confirm"], + lambda data: connection.send_message( + websocket_api.messages.event_message(msg["id"], data) + ), + on_channel_teardown, + ) + + connection.subscriptions[msg["id"]] = lambda: hass.async_create_task( + channel.async_teardown() + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 26d196f8af9..b7a1e9db8e7 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, ) from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) @@ -45,6 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ADDRESS, @@ -76,7 +79,6 @@ from .const import ( CONF_RETRY_ON_EMPTY, CONF_REVERSE_ORDER, CONF_SCALE, - CONF_STATE_CLASS, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OFF, @@ -369,15 +371,24 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema( ), } ) +SERVICE_STOP_START_SCHEMA = vol.Schema( + { + vol.Required(ATTR_HUB): cv.string, + } +) def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: """Return modbus hub with name.""" - return hass.data[DOMAIN][name] + return cast(ModbusHub, hass.data[DOMAIN][name]) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Modbus component.""" return await async_modbus_setup( - hass, config, SERVICE_WRITE_REGISTER_SCHEMA, SERVICE_WRITE_COIL_SCHEMA + hass, + config, + SERVICE_WRITE_REGISTER_SCHEMA, + SERVICE_WRITE_COIL_SCHEMA, + SERVICE_STOP_START_SCHEMA, ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index efcb70b5b16..95f8d33b366 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -2,10 +2,11 @@ from __future__ import annotations from abc import abstractmethod -from datetime import timedelta +from collections.abc import Callable +from datetime import datetime, timedelta import logging import struct -from typing import Any +from typing import Any, cast from homeassistant.const import ( CONF_ADDRESS, @@ -21,11 +22,14 @@ from homeassistant.const import ( CONF_STRUCTURE, STATE_ON, ) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity from .const import ( + ACTIVE_SCAN_INTERVAL, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -50,6 +54,8 @@ from .const import ( CONF_VERIFY, CONF_WRITE_TYPE, DATA_TYPE_STRING, + SIGNAL_START_ENTITY, + SIGNAL_STOP_ENTITY, ) from .modbus import ModbusHub @@ -70,9 +76,11 @@ class BasePlatform(Entity): self._slave = entry.get(CONF_SLAVE, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] - self._value = None + self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._call_active = False + self._cancel_timer: Callable[[], None] | None = None + self._cancel_call: Callable[[], None] | None = None self._attr_name = entry[CONF_NAME] self._attr_should_poll = False @@ -83,16 +91,44 @@ class BasePlatform(Entity): self._lazy_errors = self._lazy_error_count @abstractmethod - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Virtual function to be overwritten.""" - async def async_base_added_to_hass(self): - """Handle entity which will be added.""" + @callback + def async_run(self) -> None: + """Remote start entity.""" + self.async_hold(update=False) + if self._scan_interval == 0 or self._scan_interval > ACTIVE_SCAN_INTERVAL: + self._cancel_call = async_call_later(self.hass, 1, self.async_update) if self._scan_interval > 0: - cancel_func = async_track_time_interval( + self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) ) - self._hub.entity_timers.append(cancel_func) + self._attr_available = True + self.async_write_ha_state() + + @callback + def async_hold(self, update: bool = True) -> None: + """Remote stop entity.""" + if self._cancel_call: + self._cancel_call() + self._cancel_call = None + if self._cancel_timer: + self._cancel_timer() + self._cancel_timer = None + if update: + self._attr_available = False + self.async_write_ha_state() + + async def async_base_added_to_hass(self) -> None: + """Handle entity which will be added.""" + self.async_run() + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold) + ) + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_START_ENTITY, self.async_run) + ) class BaseStructPlatform(BasePlatform, RestoreEntity): @@ -103,13 +139,13 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): super().__init__(hub, config) self._swap = config[CONF_SWAP] self._data_type = config[CONF_DATA_TYPE] - self._structure = config.get(CONF_STRUCTURE) + self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] self._offset = config[CONF_OFFSET] self._count = config[CONF_COUNT] - def _swap_registers(self, registers): + def _swap_registers(self, registers: list[int]) -> list[int]: """Do swap as needed.""" if self._swap in (CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE): # convert [12][34] --> [21][43] @@ -124,7 +160,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def unpack_structure_result(self, registers): + def unpack_structure_result(self, registers: list[int]) -> str: """Convert registers to proper result.""" registers = self._swap_registers(registers) @@ -152,14 +188,14 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): return ",".join(map(str, v_result)) # Apply scale and precision to floats and ints - val = self._scale * val[0] + self._offset + val_result: float | int = 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: - return str(val) - return f"{float(val):.{self._precision}f}" + if isinstance(val_result, int) and self._precision == 0: + return str(val_result) + return f"{float(val_result):.{self._precision}f}" class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): @@ -190,7 +226,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): CALL_TYPE_WRITE_REGISTERS, ), } - self._write_type = convert[config[CONF_WRITE_TYPE]][1] + self._write_type = cast(str, 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: @@ -209,14 +245,14 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): else: self._verify_active = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: self._attr_is_on = state.state == STATE_ON - async def async_turn(self, command): + async def async_turn(self, command: int) -> None: """Evaluate switch result.""" result = await self._hub.async_pymodbus_call( self._slave, self._address, command, self._write_type @@ -237,11 +273,11 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): else: await self.async_update() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Set switch off.""" await self.async_turn(self._command_off) - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the entity state.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index adc5e2d28f1..d3a8578f47d 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -1,11 +1,13 @@ """Support for Modbus Coil and Discrete Input sensors.""" from __future__ import annotations +from datetime import datetime import logging from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -19,9 +21,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Set up the Modbus binary sensors.""" sensors = [] @@ -38,14 +40,14 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: self._attr_is_on = state.state == STATE_ON - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" # do not allow multiple active calls to the same platform diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 0a89610a2f5..1b1a09cf9cb 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -1,6 +1,7 @@ """Support for Generic Modbus Thermostats.""" from __future__ import annotations +from datetime import datetime import logging import struct from typing import Any @@ -12,7 +13,6 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ( CONF_NAME, - CONF_STRUCTURE, CONF_TEMPERATURE_UNIT, PRECISION_TENTHS, PRECISION_WHOLE, @@ -20,6 +20,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -50,9 +51,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Read configuration and create Modbus climate.""" if discovery_info is None: return @@ -77,7 +78,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): super().__init__(hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._unit = config[CONF_TEMPERATURE_UNIT] - self._structure = config[CONF_STRUCTURE] self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE self._attr_hvac_mode = HVAC_MODE_AUTO @@ -95,7 +95,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_target_temperature_step = config[CONF_TARGET_TEMP] self._attr_target_temperature_step = config[CONF_STEP] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() @@ -107,12 +107,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # Home Assistant expects this method. # We'll keep it here to avoid getting exceptions. - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: return target_temperature = ( - float(kwargs.get(ATTR_TEMPERATURE)) - self._offset + float(kwargs[ATTR_TEMPERATURE]) - self._offset ) / self._scale if self._data_type in ( DATA_TYPE_INT16, @@ -138,7 +138,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_available = result is not None await self.async_update() - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -156,7 +156,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._call_active = False self.async_write_ha_state() - async def _async_read_register(self, register_type, register) -> float | None: + async def _async_read_register( + self, register_type: str, register: int + ) -> float | None: """Read register using the Modbus hub slave.""" result = await self._hub.async_pymodbus_call( self._slave, register, self._count, register_type @@ -172,7 +174,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._lazy_errors = self._lazy_error_count self._value = self.unpack_structure_result(result.registers) self._attr_available = True - - if self._value is None: + if not self._value: return None return float(self._value) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b259b93285f..d3240565982 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -41,7 +41,6 @@ CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_REVERSE_ORDER = "reverse_order" CONF_PRECISION = "precision" CONF_SCALE = "scale" -CONF_STATE_CLASS = "state_class" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OFF = "state_off" @@ -107,17 +106,23 @@ CALL_TYPE_X_REGISTER_HOLDINGS = "holdings" # service calls SERVICE_WRITE_COIL = "write_coil" SERVICE_WRITE_REGISTER = "write_register" +SERVICE_STOP = "stop" +SERVICE_RESTART = "restart" + +# dispatcher signals +SIGNAL_STOP_ENTITY = "modbus.stop" +SIGNAL_START_ENTITY = "modbus.start" # integration names DEFAULT_HUB = "modbus_hub" DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" - - DEFAULT_TEMP_UNIT = "C" MODBUS_DOMAIN = "modbus" +ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update + PLATFORMS = ( (BINARY_SENSOR_DOMAIN, CONF_BINARY_SENSORS), (CLIMATE_DOMAIN, CONF_CLIMATES), diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 5fa77eb1cb8..805f07bad40 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -1,6 +1,7 @@ """Support for Modbus covers.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -41,9 +43,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Read configuration and create Modbus cover.""" if discovery_info is None: # pragma: no cover return @@ -74,6 +76,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._status_register_type = config[CONF_STATUS_REGISTER_TYPE] self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + self._attr_is_closed = False # 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. @@ -96,7 +99,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._address = self._status_register self._input_type = self._status_register_type - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() @@ -111,7 +114,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): } self._set_attr_state(convert[state.state]) - def _set_attr_state(self, value): + def _set_attr_state(self, value: str | bool | int) -> None: """Convert received value to HA state.""" self._attr_is_opening = value == self._state_opening self._attr_is_closing = value == self._state_closing @@ -133,7 +136,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._attr_available = result is not None await self.async_update() - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index cf5c9762db8..349ae0d0619 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.fan import FanEntity from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseSwitch @@ -18,8 +20,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Read configuration and create Modbus fans.""" if discovery_info is None: # pragma: no cover return @@ -39,13 +44,13 @@ class ModbusFan(BaseSwitch, FanEntity): speed: str | None = None, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Set fan on.""" await self.async_turn(self.command_on) @property - def is_on(self): + def is_on(self) -> bool: """Return true if fan is on. This is needed due to the ongoing conversion of fan. diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index dd9a8ad754d..f0f2541ad0f 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.light import LightEntity from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseSwitch @@ -17,8 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Read configuration and create Modbus lights.""" if discovery_info is None: # pragma: no cover return @@ -33,6 +38,6 @@ async def async_setup_platform( class ModbusLight(BaseSwitch, LightEntity): """Class representing a Modbus light.""" - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Set light on.""" await self.async_turn(self.command_on) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 4889b27faf0..e81afc968ca 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -3,12 +3,21 @@ from __future__ import annotations import asyncio from collections import namedtuple +from collections.abc import Callable import logging +from typing import Any -from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.client.sync import ( + BaseModbusClient, + ModbusSerialClient, + ModbusTcpClient, + ModbusUdpClient, +) from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ModbusResponse from pymodbus.transaction import ModbusRtuFramer +import voluptuous as vol from homeassistant.const import ( CONF_DELAY, @@ -20,9 +29,11 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import Event, async_call_later +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ADDRESS, @@ -51,8 +62,12 @@ from .const import ( PLATFORMS, RTUOVERTCP, SERIAL, + SERVICE_RESTART, + SERVICE_STOP, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, + SIGNAL_START_ENTITY, + SIGNAL_STOP_ENTITY, TCP, UDP, ) @@ -107,8 +122,12 @@ PYMODBUS_CALL = [ async def async_modbus_setup( - hass, config, service_write_register_schema, service_write_coil_schema -): + hass: HomeAssistant, + config: ConfigType, + service_write_register_schema: vol.Schema, + service_write_coil_schema: vol.Schema, + service_stop_start_schema: vol.Schema, +) -> bool: """Set up Modbus component.""" hass.data[DOMAIN] = hub_collect = {} @@ -128,29 +147,29 @@ async def async_modbus_setup( async_load_platform(hass, component, DOMAIN, conf_hub, config) ) - async def async_stop_modbus(event): + async def async_stop_modbus(event: Event) -> None: """Stop Modbus service.""" + async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) for client in hub_collect.values(): await client.async_close() - del client hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus) - async def async_write_register(service): + async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" unit = int(float(service.data[ATTR_UNIT])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] - client_name = ( + hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ) + ] if isinstance(value, list): - await hub_collect[client_name].async_pymodbus_call( + await hub.async_pymodbus_call( unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS ) else: - await hub_collect[client_name].async_pymodbus_call( + await hub.async_pymodbus_call( unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER ) @@ -161,49 +180,61 @@ async def async_modbus_setup( schema=service_write_register_schema, ) - async def async_write_coil(service): + async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" unit = service.data[ATTR_UNIT] address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] - client_name = ( + hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ) + ] if isinstance(state, list): - await hub_collect[client_name].async_pymodbus_call( - unit, address, state, CALL_TYPE_WRITE_COILS - ) + await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COILS) else: - await hub_collect[client_name].async_pymodbus_call( - unit, address, state, CALL_TYPE_WRITE_COIL - ) + await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COIL) hass.services.async_register( DOMAIN, SERVICE_WRITE_COIL, async_write_coil, schema=service_write_coil_schema ) + + async def async_stop_hub(service: ServiceCall) -> None: + """Stop Modbus hub.""" + async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) + hub = hub_collect[service.data[ATTR_HUB]] + await hub.async_close() + + hass.services.async_register( + DOMAIN, SERVICE_STOP, async_stop_hub, schema=service_stop_start_schema + ) + + async def async_restart_hub(service: ServiceCall) -> None: + """Restart Modbus hub.""" + async_dispatcher_send(hass, SIGNAL_START_ENTITY) + hub = hub_collect[service.data[ATTR_HUB]] + await hub.async_restart() + + hass.services.async_register( + DOMAIN, SERVICE_RESTART, async_restart_hub, schema=service_stop_start_schema + ) return True class ModbusHub: """Thread safe wrapper class for pymodbus.""" - name: str - - entity_timers: list[CALLBACK_TYPE] = [] - - def __init__(self, hass, client_config): + def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" # generic configuration - self._client = None - self._async_cancel_listener = None + self._client: BaseModbusClient | None = None + self._async_cancel_listener: Callable[[], None] | None = None self._in_error = False self._lock = asyncio.Lock() self.hass = hass self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] - self._pb_call = {} + self._pb_call: dict[str, RunEntry] = {} self._pb_class = { SERIAL: ModbusSerialClient, TCP: ModbusTcpClient, @@ -242,7 +273,7 @@ class ModbusHub: else: self._msg_wait = 0 - def _log_error(self, text: str, error_state=True): + def _log_error(self, text: str, error_state: bool = True) -> None: log_text = f"Pymodbus: {self.name}: {text}" if self._in_error: _LOGGER.debug(log_text) @@ -250,7 +281,7 @@ class ModbusHub: _LOGGER.error(log_text) self._in_error = error_state - async def async_setup(self): + async def async_setup(self) -> bool: """Set up pymodbus client.""" try: self._client = self._pb_class[self._config_type](**self._pb_params) @@ -265,7 +296,7 @@ class ModbusHub: await self.async_connect_task() return True - async def async_connect_task(self): + async def async_connect_task(self) -> None: """Try to connect, and retry if needed.""" async with self._lock: if not await self.hass.async_add_executor_job(self._pymodbus_connect): @@ -280,35 +311,51 @@ class ModbusHub: ) @callback - def async_end_delay(self, args): + def async_end_delay(self, args: Any) -> None: """End startup delay.""" self._async_cancel_listener = None self._config_delay = 0 - async def async_close(self): + async def async_restart(self) -> None: + """Reconnect client.""" + if self._client: + await self.async_close() + + await self.async_setup() + + async def async_close(self) -> None: """Disconnect client.""" if self._async_cancel_listener: self._async_cancel_listener() self._async_cancel_listener = None - for call in self.entity_timers: - call() - self.entity_timers = [] - if self._client: - try: - self._client.close() - except ModbusException as exception_error: - self._log_error(str(exception_error)) - self._client = None + async with self._lock: + if self._client: + try: + self._client.close() + except ModbusException as exception_error: + self._log_error(str(exception_error)) + del self._client + self._client = None + message = f"modbus {self.name} communication closed" + _LOGGER.warning(message) - def _pymodbus_connect(self): + def _pymodbus_connect(self) -> bool: """Connect client.""" + if not self._client: + return False try: - return self._client.connect() + self._client.connect() except ModbusException as exception_error: self._log_error(str(exception_error), error_state=False) return False + else: + message = f"modbus {self.name} communication open" + _LOGGER.warning(message) + return True - def _pymodbus_call(self, unit, address, value, use_call): + def _pymodbus_call( + self, unit: int, address: int, value: int | list[int], use_call: str + ) -> ModbusResponse: """Call sync. pymodbus.""" kwargs = {"unit": unit} if unit else {} entry = self._pb_call[use_call] @@ -323,13 +370,19 @@ class ModbusHub: self._in_error = False return result - async def async_pymodbus_call(self, unit, address, value, use_call): + async def async_pymodbus_call( + self, + unit: int | None, + address: int, + value: int | list[int], + use_call: str, + ) -> ModbusResponse | None: """Convert async to sync pymodbus call.""" if self._config_delay: return None - if not self._client: - return None async with self._lock: + if not self._client: + return None result = await self.hass.async_add_executor_job( self._pymodbus_call, unit, address, value, use_call ) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index c2f69065196..6702e6f22d1 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,18 +1,19 @@ """Support for Modbus Register sensors.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorEntity from homeassistant.const import CONF_NAME, CONF_SENSORS, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseStructPlatform -from .const import CONF_STATE_CLASS from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -22,9 +23,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, -): +) -> None: """Set up the Modbus sensors.""" sensors = [] @@ -51,14 +52,14 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: self._attr_native_value = state.state - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 855303aef07..835927e4627 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -66,3 +66,25 @@ write_register: default: "modbus_hub" selector: text: +stop: + name: Stop + description: Stop modbus hub. + fields: + hub: + name: Hub + description: Modbus hub name. + example: "hub1" + default: "modbus_hub" + selector: + text: +restart: + name: Restart + description: Restart modbus hub (if running stop then start). + fields: + hub: + name: Hub + description: Modbus hub name. + example: "hub1" + default: "modbus_hub" + selector: + text: diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 55dc014420f..86cba7c36ff 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME, CONF_SWITCHES from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .base_platform import BaseSwitch @@ -17,8 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Read configuration and create Modbus switches.""" switches = [] @@ -34,6 +39,6 @@ async def async_setup_platform( class ModbusSwitch(BaseSwitch, SwitchEntity): """Base class representing a Modbus switch.""" - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Set switch on.""" await self.async_turn(self.command_on) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index df4fe3c1e62..3d13178bccc 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -84,7 +84,7 @@ DEFAULT_STRUCT_FORMAT = { } -def struct_validator(config): +def struct_validator(config: dict[str, Any]) -> dict[str, Any]: """Sensor schema validator.""" data_type = config[CONF_DATA_TYPE] @@ -154,13 +154,11 @@ def number_validator(value: Any) -> int | float: return value try: - value = int(value) - return value + return int(value) except (TypeError, ValueError): pass try: - value = float(value) - return value + return float(value) except (TypeError, ValueError) as err: raise vol.Invalid(f"invalid number {value}") from err diff --git a/homeassistant/components/modem_callerid/__init__.py b/homeassistant/components/modem_callerid/__init__.py index 0ce41b0ea03..afa79f1d210 100644 --- a/homeassistant/components/modem_callerid/__init__.py +++ b/homeassistant/components/modem_callerid/__init__.py @@ -1 +1,37 @@ -"""The modem_callerid component.""" +"""The Modem Caller ID integration.""" +from phone_modem import PhoneModem + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DATA_KEY_API, DOMAIN, EXCEPTIONS + +PLATFORMS = [SENSOR_DOMAIN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Modem Caller ID from a config entry.""" + device = entry.data[CONF_DEVICE] + api = PhoneModem(device) + try: + await api.initialize(device) + except EXCEPTIONS as ex: + raise ConfigEntryNotReady(f"Unable to open port: {device}") from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_KEY_API: api} + 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: + api = hass.data[DOMAIN].pop(entry.entry_id)[DATA_KEY_API] + await api.close() + + return unload_ok diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py new file mode 100644 index 00000000000..fbb68381c41 --- /dev/null +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -0,0 +1,142 @@ +"""Config flow for Modem Caller ID integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from phone_modem import DEFAULT_PORT, PhoneModem +import serial.tools.list_ports +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({"name": str, "device": str}) + + +def _generate_unique_id(port: Any) -> str: + """Generate unique id from usb attributes.""" + return f"{port.vid}:{port.pid}_{port.serial_number}_{port.manufacturer}_{port.description}" + + +class PhoneModemFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Phone Modem.""" + + def __init__(self) -> None: + """Set up flow instance.""" + self._device: str | None = None + + async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + """Handle USB Discovery.""" + device = discovery_info["device"] + + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = f"{discovery_info['vid']}:{discovery_info['pid']}_{discovery_info['serial_number']}_{discovery_info['manufacturer']}_{discovery_info['description']}" + if ( + await self.validate_device_errors(dev_path=dev_path, unique_id=unique_id) + is None + ): + self._device = dev_path + return await self.async_step_usb_confirm() + return self.async_abort(reason="cannot_connect") + + async def async_step_usb_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle USB Discovery confirmation.""" + if user_input is not None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={CONF_DEVICE: self._device}, + ) + self._set_confirm_only() + return self.async_show_form(step_id="usb_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + existing_devices = [ + entry.data[CONF_DEVICE] for entry in self._async_current_entries() + ] + unused_ports = [ + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + for port in ports + if port.device not in existing_devices + ] + if not unused_ports: + return self.async_abort(reason="no_devices_found") + + if user_input is not None: + port = ports[unused_ports.index(str(user_input.get(CONF_DEVICE)))] + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, port.device + ) + errors: dict | None = await self.validate_device_errors( + dev_path=dev_path, unique_id=_generate_unique_id(port) + ) + if errors is None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={CONF_DEVICE: dev_path}, + ) + user_input = user_input or {} + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + return self.async_show_form( + step_id="user", data_schema=schema, errors=errors or {} + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + _LOGGER.warning( + "Loading Modem_callerid via platform setup is deprecated; Please remove it from your configuration" + ) + if CONF_DEVICE not in config: + config[CONF_DEVICE] = DEFAULT_PORT + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + for port in ports: + if port.device == config[CONF_DEVICE]: + if ( + await self.validate_device_errors( + dev_path=port.device, + unique_id=_generate_unique_id(port), + ) + is None + ): + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={CONF_DEVICE: port.device}, + ) + return self.async_abort(reason="cannot_connect") + + async def validate_device_errors( + self, dev_path: str, unique_id: str + ) -> dict[str, str] | None: + """Handle common flow input validation.""" + self._async_abort_entries_match({CONF_DEVICE: dev_path}) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_DEVICE: dev_path}) + try: + api = PhoneModem() + await api.test(dev_path) + except EXCEPTIONS: + return {"base": "cannot_connect"} + else: + return None diff --git a/homeassistant/components/modem_callerid/const.py b/homeassistant/components/modem_callerid/const.py new file mode 100644 index 00000000000..b05623f8d8b --- /dev/null +++ b/homeassistant/components/modem_callerid/const.py @@ -0,0 +1,27 @@ +"""Constants for the Modem Caller ID integration.""" +from typing import Final + +from phone_modem import exceptions +from serial import SerialException + +DATA_KEY_API = "api" +DATA_KEY_COORDINATOR = "coordinator" +DEFAULT_NAME = "Phone Modem" +DOMAIN = "modem_callerid" +ICON = "mdi:phone-classic" +SERVICE_REJECT_CALL = "reject_call" + +EXCEPTIONS: Final = ( + FileNotFoundError, + exceptions.SerialError, + exceptions.ResponseError, + SerialException, +) + + +class CID: + """CID Attributes.""" + + CID_TIME = "cid_time" + CID_NUMBER = "cid_number" + CID_NAME = "cid_name" diff --git a/homeassistant/components/modem_callerid/manifest.json b/homeassistant/components/modem_callerid/manifest.json index a3bb7b676f0..4f4264d7688 100644 --- a/homeassistant/components/modem_callerid/manifest.json +++ b/homeassistant/components/modem_callerid/manifest.json @@ -1,8 +1,11 @@ { "domain": "modem_callerid", - "name": "Modem Caller ID", + "name": "Phone Modem", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/modem_callerid", - "requirements": ["basicmodem==0.7"], - "codeowners": [], - "iot_class": "local_polling" + "requirements": ["phone_modem==0.1.1"], + "codeowners": ["@tkdrob"], + "dependencies": ["usb"], + "iot_class": "local_polling", + "usb": [{"vid":"0572","pid":"1340"}] } diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index afbc09eb45c..6c08ea8d6cf 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -1,121 +1,126 @@ """A sensor for incoming calls using a USB modem that supports caller ID.""" -import logging +from __future__ import annotations -from basicmodem.basicmodem import BasicModem as bm +from phone_modem import DEFAULT_PORT, PhoneModem import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.typing import DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Modem CallerID" -ICON = "mdi:phone-classic" -DEFAULT_DEVICE = "/dev/ttyACM0" +from .const import CID, DATA_KEY_API, DEFAULT_NAME, DOMAIN, ICON, SERVICE_REJECT_CALL -STATE_RING = "ring" -STATE_CALLERID = "callerid" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, - } +# Deprecated in Home Assistant 2021.10 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_PORT): cv.string, + } + ) + ) ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up modem caller ID sensor platform.""" +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Modem Caller ID component.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - name = config.get(CONF_NAME) - port = config.get(CONF_DEVICE) - modem = bm(port) - if modem.state == modem.STATE_FAILED: - _LOGGER.error("Unable to initialize modem") - return +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Modem Caller ID sensor.""" + api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] + async_add_entities( + [ + ModemCalleridSensor( + api, + entry.title, + entry.data[CONF_DEVICE], + entry.entry_id, + ) + ] + ) - add_entities([ModemCalleridSensor(hass, name, port, modem)]) + async def _async_on_hass_stop(self) -> None: + """HA is shutting down, close modem port.""" + if hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]: + await hass.data[DOMAIN][entry.entry_id][DATA_KEY_API].close() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_hass_stop) + ) + + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service(SERVICE_REJECT_CALL, {}, "async_reject_call") class ModemCalleridSensor(SensorEntity): """Implementation of USB modem caller ID sensor.""" - def __init__(self, hass, name, port, modem): + _attr_icon = ICON + _attr_should_poll = False + + def __init__( + self, api: PhoneModem, name: str, device: str, server_unique_id: str + ) -> None: """Initialize the sensor.""" - self._attributes = {"cid_time": 0, "cid_number": "", "cid_name": ""} - self._name = name - self.port = port - self.modem = modem - self._state = STATE_IDLE - modem.registercallback(self._incomingcallcallback) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self._stop_modem) + self.device = device + self.api = api + self._attr_name = name + self._attr_unique_id = server_unique_id + self._attr_native_value = STATE_IDLE + self._attr_extra_state_attributes = { + CID.CID_TIME: 0, + CID.CID_NUMBER: "", + CID.CID_NAME: "", + } - def set_state(self, state): - """Set the state.""" - self._state = state + async def async_added_to_hass(self) -> None: + """Call when the modem sensor is added to Home Assistant.""" + self.api.registercallback(self._async_incoming_call) + await super().async_added_to_hass() - def set_attributes(self, attributes): - """Set the state attributes.""" - self._attributes = attributes - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - def _stop_modem(self, event): - """HA is shutting down, close modem port.""" - if self.modem: - self.modem.close() - self.modem = None - - def _incomingcallcallback(self, newstate): + @callback + def _async_incoming_call(self, new_state) -> None: """Handle new states.""" - if newstate == self.modem.STATE_RING: - if self.state == self.modem.STATE_IDLE: - att = { - "cid_time": self.modem.get_cidtime, - "cid_number": "", - "cid_name": "", + if new_state == PhoneModem.STATE_RING: + if self.native_value == PhoneModem.STATE_IDLE: + self._attr_extra_state_attributes = { + CID.CID_NUMBER: "", + CID.CID_NAME: "", } - self.set_attributes(att) - self._state = STATE_RING - self.schedule_update_ha_state() - elif newstate == self.modem.STATE_CALLERID: - att = { - "cid_time": self.modem.get_cidtime, - "cid_number": self.modem.get_cidnumber, - "cid_name": self.modem.get_cidname, + elif new_state == PhoneModem.STATE_CALLERID: + self._attr_extra_state_attributes = { + CID.CID_NUMBER: self.api.cid_number, + CID.CID_NAME: self.api.cid_name, } - self.set_attributes(att) - self._state = STATE_CALLERID - self.schedule_update_ha_state() - elif newstate == self.modem.STATE_IDLE: - self._state = STATE_IDLE - self.schedule_update_ha_state() + self._attr_extra_state_attributes[CID.CID_TIME] = self.api.cid_time + self._attr_native_value = self.api.state + self.async_write_ha_state() + + async def async_reject_call(self) -> None: + """Reject Incoming Call.""" + await self.api.reject_call(self.device) diff --git a/homeassistant/components/modem_callerid/services.yaml b/homeassistant/components/modem_callerid/services.yaml new file mode 100644 index 00000000000..7ec8aaf3f94 --- /dev/null +++ b/homeassistant/components/modem_callerid/services.yaml @@ -0,0 +1,7 @@ +reject_call: + name: Reject Call + description: Reject incoming call. + target: + entity: + integration: modem_callerid + domain: sensor diff --git a/homeassistant/components/modem_callerid/strings.json b/homeassistant/components/modem_callerid/strings.json new file mode 100644 index 00000000000..17359128528 --- /dev/null +++ b/homeassistant/components/modem_callerid/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "user": { + "title": "Phone Modem", + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "usb_confirm": { + "title": "Phone Modem", + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_devices_found": "No remaining devices found" + } + } + } \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/ca.json b/homeassistant/components/modem_callerid/translations/ca.json new file mode 100644 index 00000000000..d94d4cf392d --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'ha trobat cap dispositiu restant" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "usb_confirm": { + "description": "Integraci\u00f3 per a trucades fixes amb el m\u00f2dem de veu CX93001. Pot obtenir l'identificador del que truca i pot rebutjar trucades entrants.", + "title": "M\u00f2dem telef\u00f2nic" + }, + "user": { + "data": { + "name": "Nom", + "port": "Port" + }, + "description": "Integraci\u00f3 per a trucades fixes amb el m\u00f2dem de veu CX93001. Pot obtenir l'identificador del que truca i pot rebutjar trucades entrants.", + "title": "M\u00f2dem telef\u00f2nic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/cs.json b/homeassistant/components/modem_callerid/translations/cs.json new file mode 100644 index 00000000000..05861d2c427 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "name": "Jm\u00e9no", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/de.json b/homeassistant/components/modem_callerid/translations/de.json new file mode 100644 index 00000000000..0bc505be5c8 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine weiteren Ger\u00e4te gefunden" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "usb_confirm": { + "description": "Dies ist eine Integration f\u00fcr Festnetzanrufe mit einem CX93001 Sprachmodem. Damit k\u00f6nnen Anrufer-ID-Informationen mit einer Option zum Abweisen eines eingehenden Anrufs abgerufen werden.", + "title": "Telefonmodem" + }, + "user": { + "data": { + "name": "Name", + "port": "Port" + }, + "description": "Dies ist eine Integration f\u00fcr Festnetzanrufe mit einem CX93001 Sprachmodem. Damit k\u00f6nnen Anrufer-ID-Informationen mit einer Option zum Abweisen eines eingehenden Anrufs abgerufen werden.", + "title": "Telefonmodem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/en.json b/homeassistant/components/modem_callerid/translations/en.json new file mode 100644 index 00000000000..5450a930ff3 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No remaining devices found" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "usb_confirm": { + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call.", + "title": "Phone Modem" + }, + "user": { + "data": { + "name": "Name", + "port": "Port" + }, + "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call.", + "title": "Phone Modem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/es.json b/homeassistant/components/modem_callerid/translations/es.json new file mode 100644 index 00000000000..eaf0a9afea1 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso" + }, + "step": { + "user": { + "data": { + "name": "Nombre", + "port": "Puerto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/et.json b/homeassistant/components/modem_callerid/translations/et.json new file mode 100644 index 00000000000..463d24e8f9f --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/et.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine juba k\u00e4ib", + "no_devices_found": "Lisatavaid seadmeid ei leitud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "usb_confirm": { + "description": "See on sidumine fiksv\u00f5rgu telefonile kasutades CX93001 modemit. See v\u00f5ib hankida helistaja ID teabe koos sissetulevast k\u00f5nestloobumise v\u00f5imalusega.", + "title": "Telefoniliini modem" + }, + "user": { + "data": { + "name": "Nimi", + "port": "Port" + }, + "description": "See on sidumine fiksv\u00f5rgu telefonile kasutades CX93001 modemit. See v\u00f5ib hankida helistaja ID teabe koos sissetulevast k\u00f5nestloobumise v\u00f5imalusega.", + "title": "Telefoniliini modem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/he.json b/homeassistant/components/modem_callerid/translations/he.json new file mode 100644 index 00000000000..e156f21f826 --- /dev/null +++ b/homeassistant/components/modem_callerid/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", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/hu.json b/homeassistant/components/modem_callerid/translations/hu.json new file mode 100644 index 00000000000..cb8433e0028 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/hu.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 egy\u00e9b eszk\u00f6z" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "usb_confirm": { + "description": "Ez egy integr\u00e1ci\u00f3 a CX93001 hangmodemmel t\u00f6rt\u00e9n\u0151 vezet\u00e9kes h\u00edv\u00e1sokhoz. Ez k\u00e9pes lek\u00e9rdezni a h\u00edv\u00f3azonos\u00edt\u00f3 inform\u00e1ci\u00f3t a bej\u00f6v\u0151 h\u00edv\u00e1s visszautas\u00edt\u00e1s\u00e1nak lehet\u0151s\u00e9g\u00e9vel.", + "title": "Telefon modem" + }, + "user": { + "data": { + "name": "N\u00e9v", + "port": "Port" + }, + "description": "Ez egy integr\u00e1ci\u00f3 a CX93001 hangmodemmel t\u00f6rt\u00e9n\u0151 vezet\u00e9kes h\u00edv\u00e1sokhoz. Ez k\u00e9pes lek\u00e9rdezni a h\u00edv\u00f3azonos\u00edt\u00f3 inform\u00e1ci\u00f3t a bej\u00f6v\u0151 h\u00edv\u00e1s visszautas\u00edt\u00e1s\u00e1nak lehet\u0151s\u00e9g\u00e9vel.", + "title": "Telefon modem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/id.json b/homeassistant/components/modem_callerid/translations/id.json new file mode 100644 index 00000000000..9e8fc6738b9 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "name": "Nama", + "port": "Port" + }, + "title": "Modem Telepon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/it.json b/homeassistant/components/modem_callerid/translations/it.json new file mode 100644 index 00000000000..65d1c74f956 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo rimanente trovato" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "usb_confirm": { + "description": "Questa \u00e8 un'integrazione per le chiamate su linea fissa che utilizza un modem vocale CX93001. Questo pu\u00f2 recuperare le informazioni sull'ID del chiamante con un'opzione per rifiutare una chiamata in arrivo.", + "title": "Modem del telefono" + }, + "user": { + "data": { + "name": "Nome", + "port": "Porta" + }, + "description": "Questa \u00e8 un'integrazione per le chiamate su linea fissa che utilizza un modem vocale CX93001. Questo pu\u00f2 recuperare le informazioni sull'ID del chiamante con un'opzione per rifiutare una chiamata in arrivo.", + "title": "Modem del telefono" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/nl.json b/homeassistant/components/modem_callerid/translations/nl.json new file mode 100644 index 00000000000..4077a03105b --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "no_devices_found": "Geen resterende apparaten gevonden" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "usb_confirm": { + "description": "Dit is een integratie voor vaste telefoongesprekken met een CX93001 spraakmodem. Hiermee kan beller-ID informatie worden opgehaald met een optie om een inkomende oproep te weigeren.", + "title": "Telefoonmodem" + }, + "user": { + "data": { + "name": "Naam", + "port": "Poort" + }, + "description": "Dit is een integratie voor vaste telefoongesprekken met een CX93001 spraakmodem. Hiermee kan beller-ID informatie worden opgehaald met een optie om een inkomende oproep te weigeren.", + "title": "Telefoonmodem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/no.json b/homeassistant/components/modem_callerid/translations/no.json new file mode 100644 index 00000000000..2e1103b5092 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen gjenv\u00e6rende enheter funnet" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "usb_confirm": { + "description": "Dette er en integrasjon for fasttelefonsamtaler ved hjelp av et talemodem CX93001. Dette kan hente oppringer -ID -informasjon med et alternativ for \u00e5 avvise et innkommende anrop.", + "title": "Telefonmodem" + }, + "user": { + "data": { + "name": "Navn", + "port": "Port" + }, + "description": "Dette er en integrasjon for fasttelefonsamtaler ved hjelp av et talemodem CX93001. Dette kan hente oppringer -ID -informasjon med et alternativ for \u00e5 avvise et innkommende anrop.", + "title": "Telefonmodem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/ru.json b/homeassistant/components/modem_callerid/translations/ru.json new file mode 100644 index 00000000000..f5fa5061a4a --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/ru.json @@ -0,0 +1,26 @@ +{ + "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_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "no_devices_found": "\u041f\u043e\u0434\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\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." + }, + "step": { + "usb_confirm": { + "description": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0433\u043e \u043c\u043e\u0434\u0435\u043c\u0430 CX93001 \u0434\u043b\u044f \u0437\u0432\u043e\u043d\u043a\u043e\u0432 \u043f\u043e \u0441\u0442\u0430\u0446\u0438\u043e\u043d\u0430\u0440\u043d\u043e\u0439 \u043b\u0438\u043d\u0438\u0438. \u041f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e\u0431 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0435 \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0449\u0435\u0433\u043e \u0430\u0431\u043e\u043d\u0435\u043d\u0442\u0430 \u0441 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u044e \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430.", + "title": "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u043d\u044b\u0439 \u043c\u043e\u0434\u0435\u043c" + }, + "user": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0433\u043e \u043c\u043e\u0434\u0435\u043c\u0430 CX93001 \u0434\u043b\u044f \u0437\u0432\u043e\u043d\u043a\u043e\u0432 \u043f\u043e \u0441\u0442\u0430\u0446\u0438\u043e\u043d\u0430\u0440\u043d\u043e\u0439 \u043b\u0438\u043d\u0438\u0438. \u041f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e\u0431 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0435 \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0449\u0435\u0433\u043e \u0430\u0431\u043e\u043d\u0435\u043d\u0442\u0430 \u0441 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u044e \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430.", + "title": "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u043d\u044b\u0439 \u043c\u043e\u0434\u0435\u043c" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/zh-Hant.json b/homeassistant/components/modem_callerid/translations/zh-Hant.json new file mode 100644 index 00000000000..542a12e8c5d --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u627e\u4e0d\u5230\u5269\u9918\u88dd\u7f6e" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "usb_confirm": { + "description": "\u6b64\u6574\u5408\u4f7f\u7528 CX93001 \u8a9e\u97f3\u6578\u64da\u6a5f\u9032\u884c\u5e02\u8a71\u901a\u8a71\u3002\u53ef\u7528\u4ee5\u6aa2\u67e5\u4f86\u96fb ID \u8cc7\u8a0a\u3001\u4e26\u9032\u884c\u62d2\u63a5\u4f86\u96fb\u7684\u529f\u80fd\u3002", + "title": "\u624b\u6a5f\u6578\u64da\u6a5f" + }, + "user": { + "data": { + "name": "\u540d\u7a31", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u6b64\u6574\u5408\u4f7f\u7528 CX93001 \u8a9e\u97f3\u6578\u64da\u6a5f\u9032\u884c\u5e02\u8a71\u901a\u8a71\u3002\u53ef\u7528\u4ee5\u6aa2\u67e5\u4f86\u96fb ID \u8cc7\u8a0a\u3001\u4e26\u9032\u884c\u62d2\u63a5\u4f86\u96fb\u7684\u529f\u80fd\u3002", + "title": "\u624b\u6a5f\u6578\u64da\u6a5f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/es.json b/homeassistant/components/modern_forms/translations/es.json index ac911baf4a4..f651dca40a5 100644 --- a/homeassistant/components/modern_forms/translations/es.json +++ b/homeassistant/components/modern_forms/translations/es.json @@ -1,8 +1,21 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar" + }, + "error": { + "cannot_connect": "No se pudo conectar" + }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" + }, "user": { + "data": { + "host": "Anfitri\u00f3n" + }, "description": "Configura tu ventilador de Modern Forms para que se integre con Home Assistant." }, "zeroconf_confirm": { diff --git a/homeassistant/components/modern_forms/translations/fr.json b/homeassistant/components/modern_forms/translations/fr.json index d68f4a7f680..cde37b5251b 100644 --- a/homeassistant/components/modern_forms/translations/fr.json +++ b/homeassistant/components/modern_forms/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Voulez-vous commencer la configuration\u00a0?" + "description": "Voulez-vous commencer la configuration ?" }, "user": { "data": { diff --git a/homeassistant/components/modern_forms/translations/hu.json b/homeassistant/components/modern_forms/translations/hu.json index fee0216224c..49f5da5339f 100644 --- a/homeassistant/components/modern_forms/translations/hu.json +++ b/homeassistant/components/modern_forms/translations/hu.json @@ -10,16 +10,16 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsa be a Modern Forms-t, hogy integr\u00e1l\u00f3djon a Home Assistant programba." + "description": "Integr\u00e1lja \u00f6ssze Modern Formst Home Assistanttal." }, "zeroconf_confirm": { - "description": "Hozz\u00e1 szeretn\u00e9 adni a(z) {name} `nev\u0171 Modern Forms rajong\u00f3t a Home Assistanthoz?", + "description": "Hozz\u00e1 szeretn\u00e9 adni `{name}`nev\u0171 Modern Forms rajong\u00f3t Home Assistanthoz?", "title": "Felfedezte a Modern Forms rajong\u00f3i eszk\u00f6zt" } } diff --git a/homeassistant/components/modern_forms/translations/id.json b/homeassistant/components/modern_forms/translations/id.json new file mode 100644 index 00000000000..8b2f9fcfa1d --- /dev/null +++ b/homeassistant/components/modern_forms/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/nl.json b/homeassistant/components/modern_forms/translations/nl.json index 5a3d63e15a7..ccbdf7d5b44 100644 --- a/homeassistant/components/modern_forms/translations/nl.json +++ b/homeassistant/components/modern_forms/translations/nl.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" }, "user": { "data": { diff --git a/homeassistant/components/monoprice/translations/fr.json b/homeassistant/components/monoprice/translations/fr.json index 9a0ffa2354d..5489fb4e0e6 100644 --- a/homeassistant/components/monoprice/translations/fr.json +++ b/homeassistant/components/monoprice/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 223ee831779..138c842b7e8 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -62,19 +62,19 @@ class MoonSensor(SensorEntity): @property def native_value(self): """Return the state of the device.""" - if self._state == 0: + if self._state < 0.5 or self._state > 27.5: return STATE_NEW_MOON - if self._state < 7: + if self._state < 6.5: return STATE_WAXING_CRESCENT - if self._state == 7: + if self._state < 7.5: return STATE_FIRST_QUARTER - if self._state < 14: + if self._state < 13.5: return STATE_WAXING_GIBBOUS - if self._state == 14: + if self._state < 14.5: return STATE_FULL_MOON - if self._state < 21: + if self._state < 20.5: return STATE_WANING_GIBBOUS - if self._state == 21: + if self._state < 21.5: return STATE_LAST_QUARTER return STATE_WANING_CRESCENT diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index f7ae6573b1b..a4fb003b546 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -3,8 +3,7 @@ from datetime import timedelta import logging from socket import timeout -from motionblinds import MotionMulticast -from motionblinds.motion_blinds import ParseException +from motionblinds import MotionMulticast, ParseException from homeassistant import config_entries, core from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -40,7 +39,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): name, update_interval=None, update_method=None, - ): + ) -> None: """Initialize global data updater.""" super().__init__( hass, @@ -137,6 +136,11 @@ async def async_setup_entry( KEY_COORDINATOR: coordinator, } + if motion_gateway.firmware is not None: + version = f"{motion_gateway.firmware}, protocol: {motion_gateway.protocol}" + else: + version = f"Protocol: {motion_gateway.protocol}" + device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -145,7 +149,7 @@ async def async_setup_entry( manufacturer=MANUFACTURER, name=entry.title, model="Wi-Fi bridge", - sw_version=motion_gateway.protocol, + sw_version=version, ) hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 60ec375a9c0..c96dff93e67 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -52,6 +52,7 @@ TILT_DEVICE_MAP = { BlindType.VenetianBlind: DEVICE_CLASS_BLIND, BlindType.ShangriLaBlind: DEVICE_CLASS_BLIND, BlindType.DoubleRoller: DEVICE_CLASS_SHADE, + BlindType.VerticalBlind: DEVICE_CLASS_BLIND, } TDBU_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 83007cf562c..b8e8add912d 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.4.10"], + "requirements": ["motionblinds==0.5.5"], "codeowners": ["@starkillerOG"], "iot_class": "local_push" } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 9c6db5d88ec..194f0ae315c 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -40,11 +40,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class MotionBatterySensor(CoordinatorEntity, SensorEntity): - """ - Representation of a Motion Battery Sensor. - - Updates are done by the cover platform. - """ + """Representation of a Motion Battery Sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY _attr_native_unit_of_measurement = PERCENTAGE @@ -91,11 +87,7 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): class MotionTDBUBatterySensor(MotionBatterySensor): - """ - Representation of a Motion Battery Sensor for a Top Down Bottom Up blind. - - Updates are done by the cover platform. - """ + """Representation of a Motion Battery Sensor for a Top Down Bottom Up blind.""" def __init__(self, coordinator, blind, motor): """Initialize the Motion Battery Sensor.""" diff --git a/homeassistant/components/motion_blinds/translations/fr.json b/homeassistant/components/motion_blinds/translations/fr.json index b6715970e40..dc883066c47 100644 --- a/homeassistant/components/motion_blinds/translations/fr.json +++ b/homeassistant/components/motion_blinds/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "connection_error": "\u00c9chec de la connexion " + "connection_error": "\u00c9chec de connexion" }, "error": { "discovery_error": "Impossible de d\u00e9couvrir une Motion Gateway" @@ -12,7 +12,7 @@ "step": { "connect": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Vous aurez besoin de la cl\u00e9 API de 16 caract\u00e8res, voir https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key pour les instructions", "title": "Stores de mouvement" @@ -26,7 +26,7 @@ }, "user": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "host": "Adresse IP" }, "description": "Connectez-vous \u00e0 votre Motion Gateway, si l'adresse IP n'est pas d\u00e9finie, la d\u00e9tection automatique est utilis\u00e9e", diff --git a/homeassistant/components/motion_blinds/translations/hu.json b/homeassistant/components/motion_blinds/translations/hu.json index a2560e5fa79..32ff2dcc58e 100644 --- a/homeassistant/components/motion_blinds/translations/hu.json +++ b/homeassistant/components/motion_blinds/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "connection_error": "Sikertelen csatlakoz\u00e1s" }, "error": { diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 3eebcd4ee53..07385f24216 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import json import logging from types import MappingProxyType -from typing import Any, Callable +from typing import Any from urllib.parse import urlencode, urljoin from aiohttp.web import Request, Response diff --git a/homeassistant/components/motioneye/translations/fr.json b/homeassistant/components/motioneye/translations/fr.json index b8d79b683a6..c7a27090396 100644 --- a/homeassistant/components/motioneye/translations/fr.json +++ b/homeassistant/components/motioneye/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "invalid_url": "URL invalide", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/motioneye/translations/hu.json b/homeassistant/components/motioneye/translations/hu.json index 5b23c74dc76..0acc46509a4 100644 --- a/homeassistant/components/motioneye/translations/hu.json +++ b/homeassistant/components/motioneye/translations/hu.json @@ -12,7 +12,7 @@ }, "step": { "hassio_confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant, hogy csatlakozzon a(z) {addon} \u00e1ltal biztos\u00edtott motionEye szolg\u00e1ltat\u00e1shoz?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot motionEyehez val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", "title": "motionEye a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "user": { @@ -30,7 +30,7 @@ "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": "\u00c1ll\u00edtsa be a motionEye webhookokat az esem\u00e9nyek jelent\u00e9s\u00e9nek Home Assistant sz\u00e1m\u00e1ra", "webhook_set_overwrite": "Fel\u00fcl\u00edrja a fel nem ismert webhookokat" } } diff --git a/homeassistant/components/motioneye/translations/ko.json b/homeassistant/components/motioneye/translations/ko.json new file mode 100644 index 00000000000..ff2a843677d --- /dev/null +++ b/homeassistant/components/motioneye/translations/ko.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\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", + "invalid_url": "\uc798\ubabb\ub41c URL", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "admin_password": "Admin \ube44\ubc00\ubc88\ud638", + "admin_username": "Admin \uc0ac\uc6a9\uc790 \uc774\ub984", + "surveillance_password": "Surveillance \ube44\ubc00\ubc88\ud638", + "surveillance_username": "Surveillance \uc0ac\uc6a9\uc790 \uc774\ub984", + "url": "URL \uc8fc\uc18c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ec5f5f6d1af..36402380b33 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -38,7 +38,7 @@ from homeassistant.core import ( ServiceCall, callback, ) -from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.typing import ConfigType, ServiceDataType @@ -153,16 +153,6 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema( ) -def embedded_broker_deprecated(value): - """Warn user that embedded MQTT broker is deprecated.""" - _LOGGER.warning( - "The embedded MQTT broker has been deprecated and will stop working" - "after June 5th, 2019. Use an external broker instead. For" - "instructions, see https://www.home-assistant.io/docs/mqtt/broker" - ) - return value - - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -495,7 +485,7 @@ async def async_setup_entry(hass, entry): payload = template.Template(payload_template, hass).async_render( parse_result=False ) - except template.jinja2.TemplateError as exc: + except (template.jinja2.TemplateError, TemplateError) as exc: _LOGGER.error( "Unable to publish to %s: rendering payload template of " "%s failed because %s", diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 57cb88e65e3..f9bb3f1c91f 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -1,7 +1,10 @@ """Helper to handle a set of topics to subscribe to.""" +from __future__ import annotations + from collections import deque +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index b4b586e14d2..fe1bd608305 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -1,13 +1,17 @@ """Provides device automations for MQTT.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import attr import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( CONF_DEVICE, @@ -86,7 +90,7 @@ class TriggerInstance: """Attached trigger settings.""" action: AutomationActionType = attr.ib() - automation_info: dict = attr.ib() + automation_info: AutomationTriggerInfo = attr.ib() trigger: Trigger = attr.ib() remove: CALLBACK_TYPE | None = attr.ib(default=None) @@ -316,7 +320,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if DEVICE_TRIGGERS not in hass.data: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 11bf70ceceb..e21f3f5c280 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -2,9 +2,9 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable import json import logging -from typing import Callable import voluptuous as vol diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 4a0ea75de21..0e5cca03ceb 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, @@ -42,7 +43,6 @@ _LOGGER = logging.getLogger(__name__) CONF_EXPIRE_AFTER = "expire_after" CONF_LAST_RESET_TOPIC = "last_reset_topic" CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" -CONF_STATE_CLASS = "state_class" MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 03259a37380..6d132b28a98 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -1,7 +1,8 @@ """Helper to handle a set of topics to subscribe to.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import attr diff --git a/homeassistant/components/mqtt/translations/ca.json b/homeassistant/components/mqtt/translations/ca.json index 108da3b1263..569adc2e423 100644 --- a/homeassistant/components/mqtt/translations/ca.json +++ b/homeassistant/components/mqtt/translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "El servei ja est\u00e0 configurat", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { diff --git a/homeassistant/components/mqtt/translations/cs.json b/homeassistant/components/mqtt/translations/cs.json index 9876e2509b3..f82a3f1c973 100644 --- a/homeassistant/components/mqtt/translations/cs.json +++ b/homeassistant/components/mqtt/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Slu\u017eba je ji\u017e nastavena", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." }, "error": { diff --git a/homeassistant/components/mqtt/translations/de.json b/homeassistant/components/mqtt/translations/de.json index 2961a69ed1b..3a71d7bc547 100644 --- a/homeassistant/components/mqtt/translations/de.json +++ b/homeassistant/components/mqtt/translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Der Dienst ist bereits konfiguriert", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { diff --git a/homeassistant/components/mqtt/translations/el.json b/homeassistant/components/mqtt/translations/el.json new file mode 100644 index 00000000000..db531604073 --- /dev/null +++ b/homeassistant/components/mqtt/translations/el.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c5\u03c0\u03b7\u03c1\u03b5\u03c3\u03af\u03b1 \u03b5\u03af\u03bd\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03bc\u03ad\u03bd\u03b7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/es.json b/homeassistant/components/mqtt/translations/es.json index 2cabe392308..50cac3172ab 100644 --- a/homeassistant/components/mqtt/translations/es.json +++ b/homeassistant/components/mqtt/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "El servicio ya est\u00e1 configurado", "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de MQTT." }, "error": { diff --git a/homeassistant/components/mqtt/translations/et.json b/homeassistant/components/mqtt/translations/et.json index 3b7a0c87f57..0fee989f25d 100644 --- a/homeassistant/components/mqtt/translations/et.json +++ b/homeassistant/components/mqtt/translations/et.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Teenus on juba seadistatud", "single_instance_allowed": "Lubatud on ainult \u00fcks MQTT konfiguratsioon." }, "error": { diff --git a/homeassistant/components/mqtt/translations/fi.json b/homeassistant/components/mqtt/translations/fi.json index 27a956beb33..bc974dfd7d9 100644 --- a/homeassistant/components/mqtt/translations/fi.json +++ b/homeassistant/components/mqtt/translations/fi.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Yhdist\u00e4minen ep\u00e4onnistui" + }, "step": { "broker": { "data": { diff --git a/homeassistant/components/mqtt/translations/fr.json b/homeassistant/components/mqtt/translations/fr.json index af13e69ab4a..13bfce8dd5e 100644 --- a/homeassistant/components/mqtt/translations/fr.json +++ b/homeassistant/components/mqtt/translations/fr.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration de MQTT est autoris\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "Impossible de se connecter au broker." + "cannot_connect": "\u00c9chec de connexion" }, "step": { "broker": { @@ -52,7 +53,7 @@ "error": { "bad_birth": "Topic de naissance invalide", "bad_will": "Topic de testament invalide", - "cannot_connect": "Impossible de se connecter au broker." + "cannot_connect": "\u00c9chec de connexion" }, "step": { "broker": { @@ -60,7 +61,7 @@ "broker": "Broker", "password": "Mot de passe", "port": "Port", - "username": "Username" + "username": "Nom d'utilisateur" }, "description": "Veuillez entrer les informations de connexion de votre broker MQTT.", "title": "Options de courtier" diff --git a/homeassistant/components/mqtt/translations/he.json b/homeassistant/components/mqtt/translations/he.json index f0c156b5fde..36521ce6839 100644 --- a/homeassistant/components/mqtt/translations/he.json +++ b/homeassistant/components/mqtt/translations/he.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \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." }, "error": { @@ -18,18 +19,45 @@ "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": { + "data": { + "discovery": "\u05d0\u05d9\u05e4\u05e9\u05d5\u05e8 \u05d2\u05d9\u05dc\u05d5\u05d9" + }, "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" } } }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e8\u05d0\u05e9\u05d5\u05df", + "button_2": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05e0\u05d9", + "button_3": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05dc\u05d9\u05e9\u05d9", + "button_4": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e8\u05d1\u05d9\u05e2\u05d9", + "button_5": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05d7\u05de\u05d9\u05e9\u05d9", + "button_6": "\u05db\u05e4\u05ea\u05d5\u05e8 \u05e9\u05d9\u05e9\u05d9", + "turn_off": "\u05db\u05d1\u05d4", + "turn_on": "\u05d4\u05e4\u05e2\u05dc" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u05d4\u05e7\u05e9\u05d4 \u05db\u05e4\u05d5\u05dc\u05d4", + "button_long_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "button_long_release": "\"{subtype}\" \u05e9\u05d5\u05d7\u05e8\u05e8 \u05dc\u05d0\u05d7\u05e8 \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "button_quadruple_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05e8\u05d5\u05d1\u05e2\u05ea", + "button_quintuple_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05d7\u05d5\u05de\u05e9\u05ea", + "button_short_press": "\"{subtype}\" \u05e0\u05dc\u05d7\u05e5", + "button_short_release": "\"{subtype}\" \u05e9\u05d5\u05d7\u05e8\u05e8", + "button_triple_press": "\"{subtype}\" \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05e9\u05d5\u05dc\u05e9\u05ea" + } + }, "options": { "error": { + "bad_birth": "\u05e0\u05d5\u05e9\u05d0 \u05dc\u05d9\u05d3\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9.", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "step": { "broker": { "data": { + "broker": "\u05d1\u05e8\u05d5\u05e7\u05e8", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "port": "\u05e4\u05ea\u05d7\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" @@ -38,6 +66,13 @@ "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05de\u05ea\u05d5\u05d5\u05da" }, "options": { + "data": { + "birth_enable": "\u05d0\u05e4\u05e9\u05e8 \u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4", + "birth_payload": "\u05de\u05d8\u05e2\u05df \u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4", + "birth_retain": "\u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4 \u05e0\u05e9\u05de\u05e8\u05ea", + "birth_topic": "\u05e0\u05d5\u05e9\u05d0 \u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4", + "discovery": "\u05d0\u05d9\u05e4\u05e9\u05d5\u05e8 \u05d2\u05d9\u05dc\u05d5\u05d9" + }, "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 a519cab55d3..9da3c6d9666 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "error": { @@ -15,14 +16,14 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait." + "description": "K\u00e9rem, adja meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait." }, "hassio_confirm": { "data": { "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se" }, - "description": "Be szeretn\u00e9d konfigru\u00e1lni, hogy a Home Assistant a(z) {addon} Supervisor add-on \u00e1ltal biztos\u00edtott MQTT br\u00f3kerhez csatlakozzon?", - "title": "MQTT Br\u00f3ker a Supervisor b\u0151v\u00edtm\u00e9nnyel" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Home Assistantot MQTT br\u00f3kerhez val\u00f3 csatlakoz\u00e1shoz, {addon} kieg\u00e9sz\u00edt\u0151 \u00e1ltal?", + "title": "MQTT Br\u00f3ker - Home Assistant kieg\u00e9sz\u00edt\u0151 \u00e1ltal" } } }, @@ -62,7 +63,7 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", + "description": "K\u00e9rem, adja meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", "title": "Br\u00f3ker opci\u00f3k" }, "options": { @@ -79,7 +80,7 @@ "will_retain": "\u00dczenet megtart\u00e1sa", "will_topic": "\u00dczenet t\u00e9m\u00e1ja" }, - "description": "Felfedez\u00e9s - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), a Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nSz\u00fclet\u00e9si \u00fczenet - A sz\u00fclet\u00e9si \u00fczenetet minden alkalommal elk\u00fcldi, amikor a Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nAkarat \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor a Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind takar\u00edt\u00e1s eset\u00e9n (pl. A Home Assistant le\u00e1ll\u00edt\u00e1sa), mind tiszt\u00e1talans\u00e1g eset\u00e9n (pl. Home Assistant \u00f6sszeomlik vagy megszakad a h\u00e1l\u00f3zati kapcsolata) bontani.", + "description": "Discovery - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), akkor Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nBirth \u00fczenet - A sz\u00fclet\u00e9si \u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nWill \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind takar\u00edt\u00e1s eset\u00e9n (pl. Home Assistant le\u00e1ll\u00edt\u00e1sa), mind rendelenes helyzetben (pl. Home Assistant \u00f6sszeomlik vagy megszakad a h\u00e1l\u00f3zati kapcsolata).", "title": "MQTT opci\u00f3k" } } diff --git a/homeassistant/components/mqtt/translations/id.json b/homeassistant/components/mqtt/translations/id.json index 2a3171456c8..14e047c1694 100644 --- a/homeassistant/components/mqtt/translations/id.json +++ b/homeassistant/components/mqtt/translations/id.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Layanan sudah dikonfigurasi", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "error": { @@ -21,8 +22,8 @@ "data": { "discovery": "Aktifkan penemuan" }, - "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke broker MQTT yang disediakan oleh add-on Supervisor {addon}?", - "title": "MQTT Broker via add-on Supervisor" + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke broker MQTT yang disediakan oleh add-on {addon}?", + "title": "MQTT Broker via add-on Home Assistant" } } }, diff --git a/homeassistant/components/mqtt/translations/it.json b/homeassistant/components/mqtt/translations/it.json index 9636e0ea446..c1a2a3745c6 100644 --- a/homeassistant/components/mqtt/translations/it.json +++ b/homeassistant/components/mqtt/translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Il servizio \u00e8 gi\u00e0 configurato", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { diff --git a/homeassistant/components/mqtt/translations/nl.json b/homeassistant/components/mqtt/translations/nl.json index e9c2469a061..542ae467e7a 100644 --- a/homeassistant/components/mqtt/translations/nl.json +++ b/homeassistant/components/mqtt/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Dienst is al geconfigureerd", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "error": { diff --git a/homeassistant/components/mqtt/translations/no.json b/homeassistant/components/mqtt/translations/no.json index fee6505862a..11f3610e033 100644 --- a/homeassistant/components/mqtt/translations/no.json +++ b/homeassistant/components/mqtt/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Tjenesten er allerede konfigurert", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { diff --git a/homeassistant/components/mqtt/translations/pl.json b/homeassistant/components/mqtt/translations/pl.json index 2103cc2c441..8b57c465af1 100644 --- a/homeassistant/components/mqtt/translations/pl.json +++ b/homeassistant/components/mqtt/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "error": { diff --git a/homeassistant/components/mqtt/translations/ru.json b/homeassistant/components/mqtt/translations/ru.json index 321a1e5e56c..c9b15c9489c 100644 --- a/homeassistant/components/mqtt/translations/ru.json +++ b/homeassistant/components/mqtt/translations/ru.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u042d\u0442\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", "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." }, "error": { diff --git a/homeassistant/components/mqtt/translations/th.json b/homeassistant/components/mqtt/translations/th.json index 293b7e34314..624df71b786 100644 --- a/homeassistant/components/mqtt/translations/th.json +++ b/homeassistant/components/mqtt/translations/th.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0e1a\u0e23\u0e34\u0e01\u0e32\u0e23\u0e19\u0e35\u0e49\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e41\u0e25\u0e49\u0e27" + }, "step": { "broker": { "data": { diff --git a/homeassistant/components/mqtt/translations/zh-Hans.json b/homeassistant/components/mqtt/translations/zh-Hans.json index 97356ed44d4..31fc4e36825 100644 --- a/homeassistant/components/mqtt/translations/zh-Hans.json +++ b/homeassistant/components/mqtt/translations/zh-Hans.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", "single_instance_allowed": "\u53ea\u5141\u8bb8\u4e00\u4e2a MQTT \u914d\u7f6e\u3002" }, "error": { diff --git a/homeassistant/components/mqtt/translations/zh-Hant.json b/homeassistant/components/mqtt/translations/zh-Hant.json index a8dc6d4ce9e..9b08ba9aee8 100644 --- a/homeassistant/components/mqtt/translations/zh-Hant.json +++ b/homeassistant/components/mqtt/translations/zh-Hant.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "error": { diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 3ee23356c3f..6be19a1b43a 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__) async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] topic = config[CONF_TOPIC] wanted_payload = config.get(CONF_PAYLOAD) value_template = config.get(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/mutesync/translations/fr.json b/homeassistant/components/mutesync/translations/fr.json index 7a292eeeeae..6e7730953c6 100644 --- a/homeassistant/components/mutesync/translations/fr.json +++ b/homeassistant/components/mutesync/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "cannot_connect": "Erreur de connexion", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Activer l'authentification dans Pr\u00e9f\u00e9rences > Authentification de m\u00fctesync", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/mutesync/translations/hu.json b/homeassistant/components/mutesync/translations/hu.json index 68cb5c18d27..0fd40705765 100644 --- a/homeassistant/components/mutesync/translations/hu.json +++ b/homeassistant/components/mutesync/translations/hu.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/mutesync/translations/id.json b/homeassistant/components/mutesync/translations/id.json new file mode 100644 index 00000000000..66c930e348b --- /dev/null +++ b/homeassistant/components/mutesync/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/myq/translations/fr.json b/homeassistant/components/myq/translations/fr.json index c07e3710645..6aa94c54577 100644 --- a/homeassistant/components/myq/translations/fr.json +++ b/homeassistant/components/myq/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "reauth_confirm": { "data": { - "password": "mot de passe" + "password": "Mot de passe" }, "description": "Le mot de passe de {username} n'est plus valide.", "title": "R\u00e9authentifiez votre compte MyQ" diff --git a/homeassistant/components/myq/translations/id.json b/homeassistant/components/myq/translations/id.json index 2cc790d15e0..4972803f37d 100644 --- a/homeassistant/components/myq/translations/id.json +++ b/homeassistant/components/myq/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Layanan sudah dikonfigurasi" + "already_configured": "Layanan 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": { "password": "Kata Sandi", diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 3d0f219c2a8..b6ad78f5dc8 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from functools import partial import logging -from typing import Callable from mysensors import BaseAsyncGateway import voluptuous as vol diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 544fb8d6b09..1dd29dbf864 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -1,7 +1,8 @@ """Support for tracking MySensors devices.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from homeassistant.components import mysensors from homeassistant.components.device_tracker import DOMAIN diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index f9410f66e8f..1f9b96e6825 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import logging import socket import sys -from typing import Any, Callable +from typing import Any import async_timeout from mysensors import BaseAsyncGateway, Message, Sensor, mysensors diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 7c50526cd6e..eba382fb52d 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -2,9 +2,9 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from enum import IntEnum import logging -from typing import Callable from mysensors import BaseAsyncGateway, Message from mysensors.sensor import ChildSensor diff --git a/homeassistant/components/mysensors/translations/he.json b/homeassistant/components/mysensors/translations/he.json index 587c3ae9132..3ade3fcdad4 100644 --- a/homeassistant/components/mysensors/translations/he.json +++ b/homeassistant/components/mysensors/translations/he.json @@ -16,6 +16,11 @@ "step": { "gw_mqtt": { "description": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05e9\u05e2\u05e8 MQTT" + }, + "gw_tcp": { + "data": { + "tcp_port": "\u05e4\u05ea\u05d7\u05d4" + } } } } diff --git a/homeassistant/components/nam/translations/fr.json b/homeassistant/components/nam/translations/fr.json index 1800e6da508..fbb2f4ae367 100644 --- a/homeassistant/components/nam/translations/fr.json +++ b/homeassistant/components/nam/translations/fr.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Hotes" + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration Nettigo Air Monitor." } diff --git a/homeassistant/components/nam/translations/hu.json b/homeassistant/components/nam/translations/hu.json index 8776ae92e20..0698b4d3e26 100644 --- a/homeassistant/components/nam/translations/hu.json +++ b/homeassistant/components/nam/translations/hu.json @@ -11,11 +11,11 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Nettigo Air Monitor-ot a {host} c\u00edmen?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Nettigo Air Monitor-ot a {host} c\u00edmen?" }, "user": { "data": { - "host": "Gazdag\u00e9p" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Nettigo Air Monitor integr\u00e1ci\u00f3j\u00e1t." } diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index be61bbc65a3..c706f52035f 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1,31 +1,28 @@ """The Nanoleaf integration.""" -from pynanoleaf.pynanoleaf import InvalidToken, Nanoleaf, Unavailable +from aionanoleaf import InvalidToken, Nanoleaf, Unavailable from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEVICE, DOMAIN, NAME, SERIAL_NO -from .util import pynanoleaf_get_info +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nanoleaf from a config entry.""" - nanoleaf = Nanoleaf(entry.data[CONF_HOST]) - nanoleaf.token = entry.data[CONF_TOKEN] + nanoleaf = Nanoleaf( + async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN] + ) try: - info = await hass.async_add_executor_job(pynanoleaf_get_info, nanoleaf) + await nanoleaf.get_info() except Unavailable as err: raise ConfigEntryNotReady from err except InvalidToken as err: raise ConfigEntryAuthFailed from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - DEVICE: nanoleaf, - NAME: info["name"], - SERIAL_NO: info["serialNo"], - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = nanoleaf hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "light") diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 9edfd23e6a9..d5fc023d3a1 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -5,17 +5,17 @@ import logging import os from typing import Any, Final, cast -from pynanoleaf import InvalidToken, Nanoleaf, NotAuthorizingNewTokens, Unavailable +from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util.json import load_json, save_json from .const import DOMAIN -from .util import pynanoleaf_get_info _LOGGER = logging.getLogger(__name__) @@ -53,9 +53,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_SCHEMA, last_step=False ) self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - self.nanoleaf = Nanoleaf(user_input[CONF_HOST]) + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), user_input[CONF_HOST] + ) try: - await self.hass.async_add_executor_job(self.nanoleaf.authorize) + await self.nanoleaf.authorize() except Unavailable: return self.async_show_form( step_id="user", @@ -63,7 +65,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, last_step=False, ) - except NotAuthorizingNewTokens: + except Unauthorized: pass except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error connecting to Nanoleaf") @@ -81,7 +83,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): config_entries.ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - self.nanoleaf = Nanoleaf(data[CONF_HOST]) + self.nanoleaf = Nanoleaf(async_get_clientsession(self.hass), data[CONF_HOST]) self.context["title_placeholders"] = {"name": self.reauth_entry.title} return await self.async_step_link() @@ -106,7 +108,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): name = discovery_info["name"].replace(f".{discovery_info['type']}", "") await self.async_set_unique_id(name) self._abort_if_unique_id_configured({CONF_HOST: host}) - self.nanoleaf = Nanoleaf(host) # Import from discovery integration self.device_id = discovery_info["properties"]["id"] @@ -116,16 +117,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): load_json, self.hass.config.path(CONFIG_FILE) ), ) - self.nanoleaf.token = self.discovery_conf.get(self.device_id, {}).get( + auth_token: str | None = self.discovery_conf.get(self.device_id, {}).get( "token", # >= 2021.4 self.discovery_conf.get(host, {}).get("token"), # < 2021.4 ) - if self.nanoleaf.token is not None: + if auth_token is not None: + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), host, auth_token + ) _LOGGER.warning( "Importing Nanoleaf %s from the discovery integration", name ) return await self.async_setup_finish(discovery_integration_import=True) - + self.nanoleaf = Nanoleaf(async_get_clientsession(self.hass), host) self.context["title_placeholders"] = {"name": name} return await self.async_step_link() @@ -137,8 +141,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link") try: - await self.hass.async_add_executor_job(self.nanoleaf.authorize) - except NotAuthorizingNewTokens: + await self.nanoleaf.authorize() + except Unauthorized: return self.async_show_form( step_id="link", errors={"base": "not_allowing_new_tokens"} ) @@ -153,7 +157,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.reauth_entry, data={ **self.reauth_entry.data, - CONF_TOKEN: self.nanoleaf.token, + CONF_TOKEN: self.nanoleaf.auth_token, }, ) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) @@ -167,8 +171,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug( "Importing Nanoleaf on %s from your configuration.yaml", config[CONF_HOST] ) - self.nanoleaf = Nanoleaf(config[CONF_HOST]) - self.nanoleaf.token = config[CONF_TOKEN] + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), config[CONF_HOST], config[CONF_TOKEN] + ) return await self.async_setup_finish() async def async_setup_finish( @@ -176,9 +181,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Finish Nanoleaf config flow.""" try: - info = await self.hass.async_add_executor_job( - pynanoleaf_get_info, self.nanoleaf - ) + await self.nanoleaf.get_info() except Unavailable: return self.async_abort(reason="cannot_connect") except InvalidToken: @@ -188,7 +191,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "Unknown error connecting with Nanoleaf at %s", self.nanoleaf.host ) return self.async_abort(reason="unknown") - name = info["name"] + name = self.nanoleaf.name await self.async_set_unique_id(name) self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host}) @@ -215,6 +218,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=name, data={ CONF_HOST: self.nanoleaf.host, - CONF_TOKEN: self.nanoleaf.token, + CONF_TOKEN: self.nanoleaf.auth_token, }, ) diff --git a/homeassistant/components/nanoleaf/const.py b/homeassistant/components/nanoleaf/const.py index 6d393fa3428..505af8ce69d 100644 --- a/homeassistant/components/nanoleaf/const.py +++ b/homeassistant/components/nanoleaf/const.py @@ -1,7 +1,3 @@ """Constants for Nanoleaf integration.""" DOMAIN = "nanoleaf" - -DEVICE = "device" -SERIAL_NO = "serial_no" -NAME = "name" diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index b50edf82179..5902beba226 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,9 +1,7 @@ """Support for Nanoleaf Lights.""" from __future__ import annotations -import logging - -from pynanoleaf import Unavailable +from aionanoleaf import Nanoleaf, Unavailable import voluptuous as vol from homeassistant.components.light import ( @@ -31,22 +29,11 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from .const import DEVICE, DOMAIN, NAME, SERIAL_NO - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +RESERVED_EFFECTS = ("*Solid*", "*Static*", "*Dynamic*") DEFAULT_NAME = "Nanoleaf" -ICON = "mdi:triangle-outline" - -SUPPORT_NANOLEAF = ( - SUPPORT_BRIGHTNESS - | SUPPORT_COLOR_TEMP - | SUPPORT_EFFECT - | SUPPORT_COLOR - | SUPPORT_TRANSITION -) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -76,96 +63,76 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nanoleaf light.""" - data = hass.data[DOMAIN][entry.entry_id] - async_add_entities([NanoleafLight(data[DEVICE], data[NAME], data[SERIAL_NO])], True) + nanoleaf: Nanoleaf = hass.data[DOMAIN][entry.entry_id] + async_add_entities([NanoleafLight(nanoleaf)]) class NanoleafLight(LightEntity): """Representation of a Nanoleaf Light.""" - def __init__(self, light, name, unique_id): + def __init__(self, nanoleaf: Nanoleaf) -> None: """Initialize an Nanoleaf light.""" - self._unique_id = unique_id - self._available = True - self._brightness = None - self._color_temp = None - self._effect = None - self._effects_list = None - self._light = light - self._name = name - self._hs_color = None - self._state = None - - @property - def available(self): - """Return availability.""" - return self._available + self._nanoleaf = nanoleaf + self._attr_unique_id = self._nanoleaf.serial_no + self._attr_name = self._nanoleaf.name + self._attr_min_mireds = 154 + self._attr_max_mireds = 833 @property def brightness(self): """Return the brightness of the light.""" - if self._brightness is not None: - return int(self._brightness * 2.55) - return None + return int(self._nanoleaf.brightness * 2.55) @property def color_temp(self): """Return the current color temperature.""" - if self._color_temp is not None: - return color_util.color_temperature_kelvin_to_mired(self._color_temp) - return None + return color_util.color_temperature_kelvin_to_mired( + self._nanoleaf.color_temperature + ) @property def effect(self): """Return the current effect.""" - return self._effect + # The API returns the *Solid* effect if the Nanoleaf is in HS or CT mode. + # The effects *Static* and *Dynamic* are not supported by Home Assistant. + # These reserved effects are implicitly set and are not in the effect_list. + # https://forum.nanoleaf.me/docs/openapi#_byoot0bams8f + return ( + None if self._nanoleaf.effect in RESERVED_EFFECTS else self._nanoleaf.effect + ) @property def effect_list(self): """Return the list of supported effects.""" - return self._effects_list - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return 154 - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return 833 - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name + return self._nanoleaf.effects_list @property def icon(self): """Return the icon to use in the frontend, if any.""" - return ICON + return "mdi:triangle-outline" @property def is_on(self): """Return true if light is on.""" - return self._state + return self._nanoleaf.is_on @property def hs_color(self): """Return the color in HS.""" - return self._hs_color + return self._nanoleaf.hue, self._nanoleaf.saturation @property def supported_features(self): """Flag supported features.""" - return SUPPORT_NANOLEAF + return ( + SUPPORT_BRIGHTNESS + | SUPPORT_COLOR_TEMP + | SUPPORT_EFFECT + | SUPPORT_COLOR + | SUPPORT_TRANSITION + ) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) hs_color = kwargs.get(ATTR_HS_COLOR) @@ -175,57 +142,40 @@ class NanoleafLight(LightEntity): if hs_color: hue, saturation = hs_color - self._light.hue = int(hue) - self._light.saturation = int(saturation) + await self._nanoleaf.set_hue(int(hue)) + await self._nanoleaf.set_saturation(int(saturation)) if color_temp_mired: - self._light.color_temperature = mired_to_kelvin(color_temp_mired) - + await self._nanoleaf.set_color_temperature( + mired_to_kelvin(color_temp_mired) + ) if transition: if brightness: # tune to the required brightness in n seconds - self._light.brightness_transition( - int(brightness / 2.55), int(transition) + await self._nanoleaf.set_brightness( + int(brightness / 2.55), transition=int(kwargs[ATTR_TRANSITION]) ) else: # If brightness is not specified, assume full brightness - self._light.brightness_transition(100, int(transition)) + await self._nanoleaf.set_brightness(100, transition=int(transition)) else: # If no transition is occurring, turn on the light - self._light.on = True + await self._nanoleaf.turn_on() if brightness: - self._light.brightness = int(brightness / 2.55) - + await self._nanoleaf.set_brightness(int(brightness / 2.55)) if effect: - if effect not in self._effects_list: + if effect not in self.effect_list: raise ValueError( f"Attempting to apply effect not in the effect list: '{effect}'" ) - self._light.effect = effect + await self._nanoleaf.set_effect(effect) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" transition = kwargs.get(ATTR_TRANSITION) - if transition: - self._light.brightness_transition(0, int(transition)) - else: - self._light.on = False + await self._nanoleaf.turn_off(transition) - def update(self): + async def async_update(self) -> None: """Fetch new state data for this light.""" try: - self._available = self._light.available - self._brightness = self._light.brightness - self._effects_list = self._light.effects - # Nanoleaf api returns non-existent effect named "*Solid*" when light set to solid color. - # This causes various issues with scening (see https://github.com/home-assistant/core/issues/36359). - # Until fixed at the library level, we should ensure the effect exists before saving to light properties - self._effect = ( - self._light.effect if self._light.effect in self._effects_list else None - ) - if self._effect is None: - self._color_temp = self._light.color_temperature - self._hs_color = self._light.hue, self._light.saturation - else: - self._color_temp = None - self._hs_color = None - self._state = self._light.on - except Unavailable as err: - _LOGGER.error("Could not update status for %s (%s)", self.name, err) - self._available = False + await self._nanoleaf.get_info() + except Unavailable: + self._attr_available = False + return + self._attr_available = True diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 42a9f512d3d..133257dc7fe 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -3,7 +3,7 @@ "name": "Nanoleaf", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", - "requirements": ["pynanoleaf==0.1.0"], + "requirements": ["aionanoleaf==0.0.2"], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."], "homekit" : { "models": [ diff --git a/homeassistant/components/nanoleaf/translations/ca.json b/homeassistant/components/nanoleaf/translations/ca.json index 80403026c91..6c966627f94 100644 --- a/homeassistant/components/nanoleaf/translations/ca.json +++ b/homeassistant/components/nanoleaf/translations/ca.json @@ -4,6 +4,7 @@ "already_configured": "El dispositiu ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_token": "Token d'acc\u00e9s no v\u00e0lid", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/cs.json b/homeassistant/components/nanoleaf/translations/cs.json new file mode 100644 index 00000000000..d66a08e73e7 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/cs.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_token": "Neplatn\u00fd p\u0159\u00edstupov\u00fd token", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/de.json b/homeassistant/components/nanoleaf/translations/de.json index b79c2995cb4..fa1b9d98057 100644 --- a/homeassistant/components/nanoleaf/translations/de.json +++ b/homeassistant/components/nanoleaf/translations/de.json @@ -4,6 +4,7 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen", "invalid_token": "Ung\u00fcltiger Zugriffs-Token", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/el.json b/homeassistant/components/nanoleaf/translations/el.json new file mode 100644 index 00000000000..5112f61ef9f --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/el.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03bc\u03ad\u03bd\u03b7", + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "invalid_token": "\u0386\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03bf \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03bb\u03ac\u03b8\u03bf\u03c2" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "not_allowing_new_tokens": "\u03a4\u03bf Nanoleaf \u03b4\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03ad\u03b1 tokens, \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 \u03c0\u03b1\u03c1\u03b1\u03c0\u03ac\u03bd\u03c9 \u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2.", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u03a0\u03b1\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c0\u03b1\u03c1\u03b1\u03c4\u03b5\u03c4\u03b1\u03bc\u03ad\u03bd\u03b1 \u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03c3\u03c4\u03bf Nanoleaf \u03b3\u03b9\u03b1 5 \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1 \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03b1\u03c1\u03c7\u03af\u03c3\u03bf\u03c5\u03bd \u03bd\u03b1 \u03b1\u03bd\u03b1\u03b2\u03bf\u03c3\u03b2\u03ae\u03bd\u03bf\u03c5\u03bd \u03bf\u03b9 \u03bb\u03c5\u03c7\u03bd\u03af\u03b5\u03c2 LED \u03c4\u03bf\u03c5 \u03ba\u03bf\u03c5\u03bc\u03c0\u03b9\u03bf\u03cd \u03ba\u03b1\u03b9, \u03c3\u03c4\u03b7 \u03c3\u03c5\u03bd\u03ad\u03c7\u03b5\u03b9\u03b1, \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af **SUBMIT** \u03bc\u03ad\u03c3\u03b1 \u03c3\u03b5 30 \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1.", + "title": "\u0394\u03b9\u03ac\u03b6\u03b5\u03c5\u03be\u03b7 Nanoleaf" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/es.json b/homeassistant/components/nanoleaf/translations/es.json new file mode 100644 index 00000000000..2efbfb875f4 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", + "invalid_token": "Token de acceso no v\u00e1lido", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "unknown": "Error inesperado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "flow_title": "{name}", + "step": { + "link": { + "title": "Link Nanoleaf" + }, + "user": { + "data": { + "host": "Anfitri\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/et.json b/homeassistant/components/nanoleaf/translations/et.json new file mode 100644 index 00000000000..15690ab3072 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "invalid_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "not_allowing_new_tokens": "Nanoleaf ei luba uusi juurdep\u00e4\u00e4sut\u00f5endeid, j\u00e4rgi \u00fclaltoodud juhiseid.", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Vajuta ja hoia Nanoleafi toitenuppu 5 sekundit all kuni nuppude LED -id hakkavad vilkuma ja seej\u00e4rel kl\u00f5psa 30 sekundi jooksul **ESITA**.", + "title": "Nanoleafi sidumine" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/fr.json b/homeassistant/components/nanoleaf/translations/fr.json new file mode 100644 index 00000000000..5893635580b --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "invalid_token": "Jeton d'acc\u00e8s non valide", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "not_allowing_new_tokens": "Nanoleaf n'autorise pas les nouveaux jetons, suivez les instructions ci-dessus.", + "unknown": "Erreur inattendue" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Appuyez sur le bouton d'alimentation de votre Nanoleaf et maintenez-le enfonc\u00e9 pendant 5 secondes jusqu'\u00e0 ce que les voyants du bouton commencent \u00e0 clignoter, puis cliquez sur **ENVOYER** dans les 30 secondes.", + "title": "Lier Nanoleaf" + }, + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/he.json b/homeassistant/components/nanoleaf/translations/he.json new file mode 100644 index 00000000000..934fb6ab98a --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/he.json @@ -0,0 +1,23 @@ +{ + "config": { + "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", + "invalid_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\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" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/hu.json b/homeassistant/components/nanoleaf/translations/hu.json new file mode 100644 index 00000000000..176d47cc38f --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "not_allowing_new_tokens": "A Nanoleaf nem enged\u00e9lyezi az \u00faj tokeneket, k\u00f6vesse a fenti utas\u00edt\u00e1sokat.", + "unknown": "V\u00e1ratlan hiba" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Nyomja meg \u00e9s tartsa lenyomva a Nanoleaf bekapcsol\u00f3gombj\u00e1t 5 m\u00e1sodpercig, am\u00edg a gomb LED-je villogni nem kezd, majd kattintson a **K\u00fcld\u00e9s** gombra 30 m\u00e1sodpercen bel\u00fcl.", + "title": "Nanoleaf link" + }, + "user": { + "data": { + "host": "C\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/id.json b/homeassistant/components/nanoleaf/translations/id.json new file mode 100644 index 00000000000..b0e3328df0b --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "invalid_token": "Token akses tidak valid", + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/it.json b/homeassistant/components/nanoleaf/translations/it.json new file mode 100644 index 00000000000..1e7517d8ada --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "invalid_token": "Token di accesso non valido", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "not_allowing_new_tokens": "Nanoleaf non permette nuovi token, segui le istruzioni qui sopra.", + "unknown": "Errore imprevisto" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Tieni premuto il pulsante di accensione sul tuo Nanoleaf per 5 secondi fino a quando i LED del pulsante iniziano a lampeggiare, quindi fai clic su **INVIA** entro 30 secondi.", + "title": "Collega Nanoleaf" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/nl.json b/homeassistant/components/nanoleaf/translations/nl.json new file mode 100644 index 00000000000..29a9f7ac58b --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "invalid_token": "Ongeldig toegangstoken", + "reauth_successful": "Herauthenticatie was succesvol", + "unknown": "Onverwachte fout" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "not_allowing_new_tokens": "Nanoleaf staat geen nieuwe tokens toe, volg de bovenstaande instructies.", + "unknown": "Onverwachte fout" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Houd de aan/uit-knop van je Nanoleaf 5 seconden ingedrukt totdat de LED's van de knoppen beginnen te knipperen en klik vervolgens binnen 30 seconden op **OPSLAAN**.", + "title": "Link Nanoleaf" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/no.json b/homeassistant/components/nanoleaf/translations/no.json new file mode 100644 index 00000000000..5c06bc4b811 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfiugrert", + "cannot_connect": "Tilkobling mislyktes", + "invalid_token": "Ugyldig tilgangstoken", + "reauth_successful": "Re-autentisering var vellykket", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "not_allowing_new_tokens": "Nanoleaf tillater ikke nye tokens, f\u00f8lg instruksjonene ovenfor.", + "unknown": "Uventet feil" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Trykk og hold inne str\u00f8mknappen p\u00e5 Nanoleaf i 5 sekunder til knappene begynner \u00e5 blinke, og klikk deretter ** SEND ** innen 30 sekunder.", + "title": "Lenke Nanoleaf" + }, + "user": { + "data": { + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/pl.json b/homeassistant/components/nanoleaf/translations/pl.json new file mode 100644 index 00000000000..1c772fa940c --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_token": "Niepoprawny token dost\u0119pu", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "not_allowing_new_tokens": "Nanoleaf nie zezwala na nowe tokeny, post\u0119puj zgodnie z powy\u017cszymi instrukcjami.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Naci\u015bnij i przytrzymaj przycisk zasilania na Nanoleaf przez 5 sekund, a\u017c dioda LED przycisku zacznie miga\u0107, a nast\u0119pnie kliknij **WY\u015aLIJ** w ci\u0105gu 30 sekund.", + "title": "Po\u0142\u0105czenie z Nanoleaf" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/ru.json b/homeassistant/components/nanoleaf/translations/ru.json new file mode 100644 index 00000000000..884ace3dedc --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/ru.json @@ -0,0 +1,28 @@ +{ + "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.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_token": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430.", + "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.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "not_allowing_new_tokens": "Nanoleaf \u043d\u0435 \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0435\u0442 \u043d\u043e\u0432\u044b\u0435 \u0442\u043e\u043a\u0435\u043d\u044b, \u0441\u043b\u0435\u0434\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u0432\u0435\u0434\u0451\u043d\u043d\u044b\u043c \u0432\u044b\u0448\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u043f\u0438\u0442\u0430\u043d\u0438\u044f Nanoleaf \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 5 \u0441\u0435\u043a\u0443\u043d\u0434, \u043f\u043e\u043a\u0430 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u043a\u043d\u043e\u043f\u043e\u043a \u043d\u0435 \u043d\u0430\u0447\u043d\u0443\u0442 \u043c\u0438\u0433\u0430\u0442\u044c, \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c** \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 30 \u0441\u0435\u043a\u0443\u043d\u0434.", + "title": "Nanoleaf" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/te.json b/homeassistant/components/nanoleaf/translations/te.json new file mode 100644 index 00000000000..1ed2d9b78fe --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/te.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "link": { + "description": "\u0c2e\u0c40 \u0c28\u0c3e\u0c28\u0c4b \u0c32\u0c40\u0c2b\u0c4d \u0c32\u0c4b\u0c28\u0c3f LED \u0c2c\u0c1f\u0c28\u0c4d\u0c32\u0c41 \u0c2b\u0c4d\u0c32\u0c3e\u0c37\u0c3f\u0c02\u0c17\u0c4d \u0c2a\u0c4d\u0c30\u0c3e\u0c30\u0c02\u0c2d\u0c2e\u0c2f\u0c4d\u0c2f\u0c47 \u0c35\u0c30\u0c15\u0c41 \u0c2a\u0c35\u0c30\u0c4d \u0c2c\u0c1f\u0c28\u0c4d\u200c\u0c28\u0c3f 5 \u0c38\u0c46\u0c15\u0c28\u0c4d\u0c32 \u0c2a\u0c3e\u0c1f\u0c41 \u0c28\u0c4a\u0c15\u0c4d\u0c15\u0c3f \u0c09\u0c02\u0c1a\u0c02\u0c21\u0c3f, \u0c06\u0c2a\u0c48 30 \u0c38\u0c46\u0c15\u0c28\u0c4d\u0c32\u0c32\u0c4b ** \u0c38\u0c2c\u0c4d\u0c2e\u0c3f\u0c1f\u0c4d ** \u0c15\u0c4d\u0c32\u0c3f\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f.", + "title": "\u0c28\u0c3e\u0c28\u0c4b\u0c32\u0c40\u0c2b\u0c4d\u200c\u0c28\u0c3f \u0c32\u0c3f\u0c02\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/th.json b/homeassistant/components/nanoleaf/translations/th.json new file mode 100644 index 00000000000..c2cf2963754 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/th.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u0e01\u0e32\u0e23\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2a\u0e34\u0e17\u0e18\u0e34\u0e4c\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + }, + "error": { + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e14\u0e34\u0e14" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u0e01\u0e14\u0e1b\u0e38\u0e48\u0e21\u0e40\u0e1b\u0e34\u0e14/\u0e1b\u0e34\u0e14\u0e1a\u0e19 Nanoleaf \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e04\u0e49\u0e32\u0e07\u0e44\u0e27\u0e49 5 \u0e27\u0e34\u0e19\u0e32\u0e17\u0e35 \u0e08\u0e19\u0e01\u0e27\u0e48\u0e32\u0e44\u0e1f LED \u0e02\u0e2d\u0e07\u0e1b\u0e38\u0e48\u0e21\u0e40\u0e23\u0e34\u0e48\u0e21\u0e01\u0e30\u0e1e\u0e23\u0e34\u0e1a \u0e08\u0e32\u0e01\u0e19\u0e31\u0e49\u0e19\u0e04\u0e25\u0e34\u0e01 **\u0e2a\u0e48\u0e07** \u0e20\u0e32\u0e22\u0e43\u0e19 30 \u0e27\u0e34\u0e19\u0e32\u0e17\u0e35", + "title": "\u0e25\u0e34\u0e07\u0e01\u0e4c\u0e01\u0e31\u0e1a Nanoleaf" + }, + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/zh-Hans.json b/homeassistant/components/nanoleaf/translations/zh-Hans.json new file mode 100644 index 00000000000..0360008af43 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/zh-Hant.json b/homeassistant/components/nanoleaf/translations/zh-Hant.json new file mode 100644 index 00000000000..cc5d1c08a8b --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "not_allowing_new_tokens": "Nanoleaf \u4e0d\u5141\u8a31\u65b0\u6b0a\u6756\uff0c\u8acb\u8ddf\u96a8\u4e0a\u65b9\u8aaa\u660e\u9032\u884c\u64cd\u4f5c\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "\u6309\u4f4f Nanoleaf \u96fb\u6e90\u9375\u4e94\u79d2\u3001\u76f4\u5230 LED \u958b\u59cb\u9583\u720d\u5f8c\uff0c\u7136\u5f8c\u65bc 30 \u79d2\u5167\u9ede\u9078 **\u50b3\u9001**\u3002", + "title": "\u9023\u7d50 Nanoleaf" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/util.py b/homeassistant/components/nanoleaf/util.py deleted file mode 100644 index 0031622e90b..00000000000 --- a/homeassistant/components/nanoleaf/util.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Nanoleaf integration util.""" -from pynanoleaf.pynanoleaf import Nanoleaf - - -def pynanoleaf_get_info(nanoleaf_light: Nanoleaf) -> dict: - """Get Nanoleaf light info.""" - return nanoleaf_light.info diff --git a/homeassistant/components/neato/translations/es.json b/homeassistant/components/neato/translations/es.json index f9b6fe54e22..64c58279614 100644 --- a/homeassistant/components/neato/translations/es.json +++ b/homeassistant/components/neato/translations/es.json @@ -8,7 +8,7 @@ "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "create_entry": { - "default": "Ver [documentaci\u00f3n Neato]({docs_url})." + "default": "Autenticado correctamente" }, "step": { "pick_implementation": { diff --git a/homeassistant/components/neato/translations/fr.json b/homeassistant/components/neato/translations/fr.json index bf11c9edfe3..4348ce3d6f5 100644 --- a/homeassistant/components/neato/translations/fr.json +++ b/homeassistant/components/neato/translations/fr.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "D\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "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.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "create_entry": { - "default": "Voir [Documentation Neato]({docs_url})." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { diff --git a/homeassistant/components/neato/translations/hu.json b/homeassistant/components/neato/translations/hu.json index 90fb417e6a6..20bc76ca6c0 100644 --- a/homeassistant/components/neato/translations/hu.json +++ b/homeassistant/components/neato/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "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\u00e9rem, 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.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, @@ -15,7 +15,7 @@ "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" }, "reauth_confirm": { - "title": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "title": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, diff --git a/homeassistant/components/neato/translations/nl.json b/homeassistant/components/neato/translations/nl.json index 3d7bbab2e75..2e9ab212fa9 100644 --- a/homeassistant/components/neato/translations/nl.json +++ b/homeassistant/components/neato/translations/nl.json @@ -15,7 +15,7 @@ "title": "Kies een authenticatie methode" }, "reauth_confirm": { - "title": "Wil je beginnen met instellen?" + "title": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 242c6147201..0a917e8cbdc 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -1,9 +1,10 @@ """Support for Google Nest SDM Cameras.""" from __future__ import annotations +from collections.abc import Callable import datetime import logging -from typing import Any, Callable +from typing import Any from google_nest_sdm.camera_traits import ( CameraEventImageTrait, @@ -222,6 +223,7 @@ class NestCamera(Camera): self, trait: EventImageGenerator ) -> bytes | None: """Return image bytes for an active event.""" + # pylint: disable=no-self-use try: event_image = await trait.generate_active_event_image() except GoogleNestException as err: diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 619f6a3fe56..bcd5b6b96b3 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -87,7 +90,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" event_config = event_trigger.TRIGGER_SCHEMA( diff --git a/homeassistant/components/nest/translations/fi.json b/homeassistant/components/nest/translations/fi.json index 5365f73b721..e4235ee096e 100644 --- a/homeassistant/components/nest/translations/fi.json +++ b/homeassistant/components/nest/translations/fi.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Odottamaton virhe" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/nest/translations/fr.json b/homeassistant/components/nest/translations/fr.json index 2d74b179d19..06f6897f364 100644 --- a/homeassistant/components/nest/translations/fr.json +++ b/homeassistant/components/nest/translations/fr.json @@ -15,14 +15,14 @@ "internal_error": "Erreur interne lors de la validation du code", "invalid_pin": "Code PIN invalide", "timeout": "D\u00e9lai de la validation du code expir\u00e9", - "unknown": "Erreur inconnue lors de la validation du code" + "unknown": "Erreur inattendue" }, "step": { "init": { "data": { "flow_impl": "Fournisseur" }, - "description": "S\u00e9lectionnez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Nest.", + "description": "S\u00e9lectionner une m\u00e9thode d'authentification", "title": "Fournisseur d'authentification" }, "link": { diff --git a/homeassistant/components/nest/translations/he.json b/homeassistant/components/nest/translations/he.json index a3b5411b536..6efee1d74bd 100644 --- a/homeassistant/components/nest/translations/he.json +++ b/homeassistant/components/nest/translations/he.json @@ -5,7 +5,8 @@ "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", - "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.", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." }, "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" @@ -28,7 +29,7 @@ "data": { "code": "\u05e7\u05d5\u05d3 PIN" }, - "description": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df Nest \u05e9\u05dc\u05da, [\u05d0\u05de\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da] ({url}). \n\n \u05dc\u05d0\u05d7\u05e8 \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8, \u05d4\u05e2\u05ea\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4PIN \u05e9\u05e1\u05d5\u05e4\u05e7 \u05d5\u05d4\u05d3\u05d1\u05e7 \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4.", + "description": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df Nest \u05e9\u05dc\u05da, [\u05d4\u05e8\u05e9\u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da]({url}). \n\n \u05dc\u05d0\u05d7\u05e8 \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8, \u05d9\u05e9 \u05dc\u05d4\u05e2\u05ea\u05d9\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4PIN \u05e9\u05e1\u05d5\u05e4\u05e7 \u05d5\u05dc\u05d4\u05d3\u05d1\u05d9\u05e7 \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4.", "title": "\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df Nest" }, "pick_implementation": { diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index 5690724c4a0..58f8ea30caf 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\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\u00e9rem, 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.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 4d6141e2dfb..5e63c56788b 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, 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 @@ -52,11 +52,6 @@ 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( - "Cameras are currently not supported with this authentication method" - ) - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] await data_handler.register_data_class( @@ -83,10 +78,16 @@ async def async_setup_entry( for camera in all_cameras ] - for person_id, person_data in data_handler.data[ - CAMERA_DATA_CLASS_NAME - ].persons.items(): - hass.data[DOMAIN][DATA_PERSONS][person_id] = person_data.get(ATTR_PSEUDO) + for home in data_class.homes.values(): + if home.get("id") is None: + continue + + hass.data[DOMAIN][DATA_PERSONS][home["id"]] = { + person_id: person_data.get(ATTR_PSEUDO) + for person_id, person_data in data_handler.data[CAMERA_DATA_CLASS_NAME] + .persons[home["id"]] + .items() + } _LOGGER.debug("Adding cameras %s", entities) async_add_entities(entities, True) @@ -309,14 +310,31 @@ 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: Any) -> None: - """Service to change current home schedule.""" - persons = kwargs.get(ATTR_PERSONS, {}) + def fetch_person_ids(self, persons: list[str | None]) -> list[str]: + """Fetch matching person ids for give list of persons.""" person_ids = [] + person_id_errors = [] + for person in persons: - for pid, data in self._data.persons.items(): + person_id = None + for pid, data in self._data.persons[self._home_id].items(): if data.get("pseudo") == person: person_ids.append(pid) + person_id = pid + break + + if person_id is None: + person_id_errors.append(person) + + if person_id_errors: + raise HomeAssistantError(f"Person(s) not registered {person_id_errors}") + + return person_ids + + async def _service_set_persons_home(self, **kwargs: Any) -> None: + """Service to change current home schedule.""" + persons = kwargs.get(ATTR_PERSONS, []) + person_ids = self.fetch_person_ids(persons) await self._data.async_set_persons_home( person_ids=person_ids, home_id=self._home_id @@ -326,24 +344,17 @@ class NetatmoCamera(NetatmoBase, Camera): 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 - if person: - for pid, data in self._data.persons.items(): - if data.get("pseudo") == person: - person_id = pid + person_ids = self.fetch_person_ids([person] if person else []) + person_id = next(iter(person_ids), None) + + await self._data.async_set_persons_away( + person_id=person_id, + home_id=self._home_id, + ) if person_id: - await self._data.async_set_persons_away( - person_id=person_id, - home_id=self._home_id, - ) - _LOGGER.debug("Set %s as away", person) - + _LOGGER.debug("Set %s as away %s", person, person_id) else: - await self._data.async_set_persons_away( - person_id=person_id, - home_id=self._home_id, - ) _LOGGER.debug("Set home as empty") async def _service_set_camera_light(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 9b7c3376076..bb6a034b19f 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -50,6 +50,8 @@ class NetatmoFlowHandler( def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" scopes = [ + "access_camera", + "access_presence", "read_camera", "read_homecoach", "read_presence", @@ -61,10 +63,6 @@ class NetatmoFlowHandler( "write_thermostat", ] - if self.flow_impl.name != "Home Assistant Cloud": - scopes.extend(["access_camera", "access_presence"]) - scopes.sort() - return {"scope": " ".join(scopes)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 777b905f5d7..a228e7632a5 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -128,7 +131,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 6c99f3c0786..f162abbaad5 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.3" + "pyatmo==6.1.0" ], "after_dependencies": [ "cloud", diff --git a/homeassistant/components/netatmo/translations/fr.json b/homeassistant/components/netatmo/translations/fr.json index 6c294d467ab..3a66f224bc2 100644 --- a/homeassistant/components/netatmo/translations/fr.json +++ b/homeassistant/components/netatmo/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "missing_configuration": "Ce composant n'est pas configur\u00e9. Veuillez suivre la documentation.", + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, @@ -11,7 +11,7 @@ }, "step": { "pick_implementation": { - "title": "Choisir une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } }, @@ -42,10 +42,10 @@ "public_weather": { "data": { "area_name": "Nom de la zone", - "lat_ne": "Latitude nord-est", - "lat_sw": "Latitude sud-ouest", - "lon_ne": "Longitude nord-est", - "lon_sw": "Longitude sud-ouest", + "lat_ne": "Latitude Nord-Est", + "lat_sw": "Latitude Sud-Ouest", + "lon_ne": "Longitude Nord-Est", + "lon_sw": "Longitude Sud-Ouest", "mode": "Calcul", "show_on_map": "Montrer sur la carte" }, diff --git a/homeassistant/components/netatmo/translations/hu.json b/homeassistant/components/netatmo/translations/hu.json index 48f084f84c2..cb634547efc 100644 --- a/homeassistant/components/netatmo/translations/hu.json +++ b/homeassistant/components/netatmo/translations/hu.json @@ -2,7 +2,7 @@ "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\u00e9rem, 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.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/netatmo/translations/pl.json b/homeassistant/components/netatmo/translations/pl.json index 449e09bfa3a..58dfb34f7fa 100644 --- a/homeassistant/components/netatmo/translations/pl.json +++ b/homeassistant/components/netatmo/translations/pl.json @@ -30,8 +30,8 @@ "outdoor": "{entity_name} wykryje zdarzenie zewn\u0119trzne", "person": "{entity_name} wykryje osob\u0119", "person_away": "{entity_name} wykryje, \u017ce osoba wysz\u0142a", - "set_point": "temperatura docelowa {entity_name} zosta\u0142a ustawiona r\u0119cznie", - "therm_mode": "{entity_name} prze\u0142\u0105czy\u0142(a) si\u0119 na \u201e{subtype}\u201d", + "set_point": "temperatura docelowa {entity_name} zostanie ustawiona r\u0119cznie", + "therm_mode": "{entity_name} prze\u0142\u0105czy si\u0119 na \"{subtype}\"", "turned_off": "{entity_name} zostanie wy\u0142\u0105czony", "turned_on": "{entity_name} zostanie w\u0142\u0105czony", "vehicle": "{entity_name} wykryje pojazd" diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 4f39d5fe5f5..9761c8298c7 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -10,6 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_EVENT_TYPE, ATTR_FACE_URL, + ATTR_HOME_ID, ATTR_IS_KNOWN, ATTR_PERSONS, DATA_DEVICE_IDS, @@ -60,9 +61,9 @@ def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: 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( - person_event_data[ATTR_ID], DEFAULT_PERSON - ) + person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS][ + event_data[ATTR_HOME_ID] + ].get(person_event_data[ATTR_ID], DEFAULT_PERSON) person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 1b55d01b463..395773c5fe3 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -1 +1,59 @@ -"""The netgear component.""" +"""Support for Netgear routers.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, PLATFORMS +from .errors import CannotLoginException +from .router import NetgearRouter + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up Netgear component.""" + router = NetgearRouter(hass, entry) + try: + await router.async_setup() + except CannotLoginException as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.unique_id] = router + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + manufacturer="Netgear", + name=router.device_name, + model=router.model, + sw_version=router.firmware_version, + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, 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.unique_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py new file mode 100644 index 00000000000..18813ac27cd --- /dev/null +++ b/homeassistant/components/netgear/config_flow.py @@ -0,0 +1,184 @@ +"""Config flow to configure the Netgear integration.""" +from urllib.parse import urlparse + +from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, DEFAULT_NAME, DOMAIN +from .errors import CannotLoginException +from .router import get_api + + +def _discovery_schema_with_defaults(discovery_info): + return vol.Schema(_ordered_shared_schema(discovery_info)) + + +def _user_schema_with_defaults(user_input): + user_schema = { + vol.Optional(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Optional(CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT)): int, + vol.Optional(CONF_SSL, default=user_input.get(CONF_SSL, False)): bool, + } + user_schema.update(_ordered_shared_schema(user_input)) + + return vol.Schema(user_schema) + + +def _ordered_shared_schema(schema_input): + return { + vol.Optional(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str, + vol.Required(CONF_PASSWORD, default=schema_input.get(CONF_PASSWORD, "")): str, + } + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Init object.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + settings_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): int, + } + ) + + return self.async_show_form(step_id="init", data_schema=settings_schema) + + +class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the netgear config flow.""" + self.placeholders = { + CONF_HOST: DEFAULT_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: DEFAULT_USER, + CONF_SSL: False, + } + self.discovered = False + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow.""" + return OptionsFlowHandler(config_entry) + + async def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + if not user_input: + user_input = {} + + if self.discovered: + data_schema = _discovery_schema_with_defaults(user_input) + else: + data_schema = _user_schema_with_defaults(user_input) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors or {}, + description_placeholders=self.placeholders, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + async def async_step_ssdp(self, discovery_info: dict) -> FlowResult: + """Initialize flow from ssdp.""" + updated_data = {} + + device_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + if device_url.hostname: + updated_data[CONF_HOST] = device_url.hostname + if device_url.port: + updated_data[CONF_PORT] = device_url.port + if device_url.scheme == "https": + updated_data[CONF_SSL] = True + else: + updated_data[CONF_SSL] = False + + await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) + self._abort_if_unique_id_configured(updates=updated_data) + self.placeholders.update(updated_data) + self.discovered = True + + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return await self._show_setup_form() + + host = user_input.get(CONF_HOST, self.placeholders[CONF_HOST]) + port = user_input.get(CONF_PORT, self.placeholders[CONF_PORT]) + ssl = user_input.get(CONF_SSL, self.placeholders[CONF_SSL]) + username = user_input.get(CONF_USERNAME, self.placeholders[CONF_USERNAME]) + password = user_input[CONF_PASSWORD] + if not username: + username = self.placeholders[CONF_USERNAME] + + # Open connection and check authentication + try: + api = await self.hass.async_add_executor_job( + get_api, password, host, username, port, ssl + ) + except CannotLoginException: + errors["base"] = "config" + + if errors: + return await self._show_setup_form(user_input, errors) + + # Check if already configured + info = await self.hass.async_add_executor_job(api.get_info) + await self.async_set_unique_id(info["SerialNumber"], raise_on_progress=False) + self._abort_if_unique_id_configured() + + config_data = { + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + } + + if info.get("ModelName") is not None and info.get("DeviceName") is not None: + name = f"{info['ModelName']} - {info['DeviceName']}" + else: + name = info.get("ModelName", DEFAULT_NAME) + + return self.async_create_entry( + title=name, + data=config_data, + ) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py new file mode 100644 index 00000000000..325d9e68cd8 --- /dev/null +++ b/homeassistant/components/netgear/const.py @@ -0,0 +1,77 @@ +"""Netgear component constants.""" +from datetime import timedelta + +DOMAIN = "netgear" + +PLATFORMS = ["device_tracker", "sensor"] + +CONF_CONSIDER_HOME = "consider_home" + +DEFAULT_CONSIDER_HOME = timedelta(seconds=180) +DEFAULT_NAME = "Netgear router" + +# update method V2 models +MODELS_V2 = [ + "Orbi", + "RBK", + "RBR", + "RBS", + "RBW", + "LBK", + "LBR", + "CBK", + "CBR", + "SRC", + "SRK", + "SRR", + "SRS", + "SXK", + "SXR", + "SXS", +] + +# Icons +DEVICE_ICONS = { + 0: "mdi:access-point-network", # Router (Orbi ...) + 1: "mdi:book-open-variant", # Amazon Kindle + 2: "mdi:android", # Android Device + 3: "mdi:cellphone-android", # Android Phone + 4: "mdi:tablet-android", # Android Tablet + 5: "mdi:router-wireless", # Apple Airport Express + 6: "mdi:disc-player", # Blu-ray Player + 7: "mdi:router-network", # Bridge + 8: "mdi:play-network", # Cable STB + 9: "mdi:camera", # Camera + 10: "mdi:router-network", # Router + 11: "mdi:play-network", # DVR + 12: "mdi:gamepad-variant", # Gaming Console + 13: "mdi:desktop-mac", # iMac + 14: "mdi:tablet-ipad", # iPad + 15: "mdi:tablet-ipad", # iPad Mini + 16: "mdi:cellphone-iphone", # iPhone 5/5S/5C + 17: "mdi:cellphone-iphone", # iPhone + 18: "mdi:ipod", # iPod Touch + 19: "mdi:linux", # Linux PC + 20: "mdi:apple-finder", # Mac Mini + 21: "mdi:desktop-tower", # Mac Pro + 22: "mdi:laptop-mac", # MacBook + 23: "mdi:play-network", # Media Device + 24: "mdi:network", # Network Device + 25: "mdi:play-network", # Other STB + 26: "mdi:power-plug", # Powerline + 27: "mdi:printer", # Printer + 28: "mdi:access-point", # Repeater + 29: "mdi:play-network", # Satellite STB + 30: "mdi:scanner", # Scanner + 31: "mdi:play-network", # SlingBox + 32: "mdi:cellphone", # Smart Phone + 33: "mdi:nas", # Storage (NAS) + 34: "mdi:switch", # Switch + 35: "mdi:television", # TV + 36: "mdi:tablet", # Tablet + 37: "mdi:desktop-classic", # UNIX PC + 38: "mdi:desktop-tower-monitor", # Windows PC + 39: "mdi:laptop-windows", # Surface + 40: "mdi:access-point-network", # Wifi Extender + 41: "mdi:apple-airplay", # Apple TV +} diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 504faef70eb..f568a506552 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -1,15 +1,14 @@ """Support for Netgear routers.""" import logging -from pprint import pformat -from pynetgear import Netgear import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - DeviceScanner, + SOURCE_TYPE_ROUTER, ) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICES, CONF_EXCLUDE, @@ -19,7 +18,13 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DEVICE_ICONS, DOMAIN +from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry _LOGGER = logging.getLogger(__name__) @@ -27,9 +32,9 @@ CONF_APS = "accesspoints" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_HOST, default=""): cv.string, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_USERNAME, default=""): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_SSL): cv.boolean, + vol.Optional(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -39,132 +44,88 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass, config): - """Validate the configuration and returns a Netgear scanner.""" - info = config[DOMAIN] - host = info[CONF_HOST] - ssl = info[CONF_SSL] - username = info[CONF_USERNAME] - password = info[CONF_PASSWORD] - port = info.get(CONF_PORT) - devices = info[CONF_DEVICES] - excluded_devices = info[CONF_EXCLUDE] - accesspoints = info[CONF_APS] +async def async_get_scanner(hass, config): + """Import Netgear configuration from YAML.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - api = Netgear(password, host, username, port, ssl) - scanner = NetgearDeviceScanner(api, devices, excluded_devices, accesspoints) + _LOGGER.warning( + "Your Netgear configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + "Loading Netgear via platform setup is now deprecated" + ) - _LOGGER.debug("Logging in") - - results = scanner.get_attached_devices() - - if results is not None: - scanner.last_results = results - else: - _LOGGER.error("Failed to Login") - return None - - return scanner + return None -class NetgearDeviceScanner(DeviceScanner): - """Queries a Netgear wireless router using the SOAP-API.""" +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up device tracker for Netgear component.""" - def __init__( - self, - api, - devices, - excluded_devices, - accesspoints, - ): - """Initialize the scanner.""" - self.tracked_devices = devices - self.excluded_devices = excluded_devices - self.tracked_accesspoints = accesspoints - self.last_results = [] - self._api = api + def generate_classes(router: NetgearRouter, device: dict): + return [NetgearScannerEntity(router, device)] - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() + async_setup_netgear_entry(hass, entry, async_add_entities, generate_classes) - devices = [] - for dev in self.last_results: - tracked = ( - not self.tracked_devices - or dev.mac in self.tracked_devices - or dev.name in self.tracked_devices - ) - tracked = tracked and ( - not self.excluded_devices - or not ( - dev.mac in self.excluded_devices - or dev.name in self.excluded_devices - ) - ) - if tracked: - devices.append(dev.mac) - if ( - self.tracked_accesspoints - and dev.conn_ap_mac in self.tracked_accesspoints - ): - devices.append(f"{dev.mac}_{dev.conn_ap_mac}") +class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): + """Representation of a device connected to a Netgear router.""" - return devices + def __init__(self, router: NetgearRouter, device: dict) -> None: + """Initialize a Netgear device.""" + super().__init__(router, device) + self._hostname = self.get_hostname() + self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") - def get_device_name(self, device): - """Return the name of the given device or the MAC if we don't know.""" - parts = device.split("_") - mac = parts[0] - ap_mac = None - if len(parts) > 1: - ap_mac = parts[1] + def get_hostname(self): + """Return the hostname of the given device or None if we don't know.""" + hostname = self._device["name"] + if hostname == "--": + return None - name = None - for dev in self.last_results: - if dev.mac == mac: - name = dev.name - break + return hostname - if not name or name == "--": - name = mac + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + self._device = self._router.devices[self._mac] + self._active = self._device["active"] + self._icon = DEVICE_ICONS.get(self._device["device_type"], "mdi:help-network") - if ap_mac: - ap_name = "Router" - for dev in self.last_results: - if dev.mac == ap_mac: - ap_name = dev.name - break + self.async_write_ha_state() - return f"{name} on {ap_name}" + @property + def is_connected(self): + """Return true if the device is connected to the router.""" + return self._active - return name + @property + def source_type(self) -> str: + """Return the source type.""" + return SOURCE_TYPE_ROUTER - def _update_info(self): - """Retrieve latest information from the Netgear router. + @property + def ip_address(self) -> str: + """Return the IP address.""" + return self._device["ip"] - Returns boolean if scanning successful. - """ - _LOGGER.debug("Scanning") + @property + def mac_address(self) -> str: + """Return the mac address.""" + return self._mac - results = self.get_attached_devices() + @property + def hostname(self) -> str: + """Return the hostname.""" + return self._hostname - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Scan result: \n%s", pformat(results)) - - if results is None: - _LOGGER.warning("Error scanning devices") - - self.last_results = results or [] - - def get_attached_devices(self): - """List attached devices with pynetgear. - - The v2 method takes more time and is more heavy on the router - so we only use it if we need connected AP info. - """ - if self.tracked_accesspoints: - return self._api.get_attached_devices_2() - - return self._api.get_attached_devices() + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon diff --git a/homeassistant/components/netgear/errors.py b/homeassistant/components/netgear/errors.py new file mode 100644 index 00000000000..2ac1ed18224 --- /dev/null +++ b/homeassistant/components/netgear/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Netgear component.""" +from homeassistant.exceptions import HomeAssistantError + + +class NetgearException(HomeAssistantError): + """Base class for Netgear exceptions.""" + + +class CannotLoginException(NetgearException): + """Unable to login to the router.""" diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index 713101f657f..aa4c57ecdde 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -2,7 +2,14 @@ "domain": "netgear", "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", - "requirements": ["pynetgear==0.6.1"], - "codeowners": [], - "iot_class": "local_polling" + "requirements": ["pynetgear==0.7.0"], + "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], + "iot_class": "local_polling", + "config_flow": true, + "ssdp": [ + { + "manufacturer": "NETGEAR, Inc.", + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + } + ] } diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py new file mode 100644 index 00000000000..53cc4f32728 --- /dev/null +++ b/homeassistant/components/netgear/router.py @@ -0,0 +1,295 @@ +"""Represent the Netgear router and its devices.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Callable +from datetime import timedelta +import logging + +from pynetgear import Netgear + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, + DEFAULT_NAME, + DOMAIN, + MODELS_V2, +) +from .errors import CannotLoginException + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +def get_api( + password: str, + host: str = None, + username: str = None, + port: int = None, + ssl: bool = False, +) -> Netgear: + """Get the Netgear API and login to it.""" + api: Netgear = Netgear(password, host, username, port, ssl) + + if not api.login(): + raise CannotLoginException + + return api + + +@callback +def async_setup_netgear_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + entity_class_generator: Callable[[NetgearRouter, dict], list], +) -> None: + """Set up device tracker for Netgear component.""" + router = hass.data[DOMAIN][entry.unique_id] + tracked = set() + + @callback + def _async_router_updated(): + """Update the values of the router.""" + async_add_new_entities( + router, async_add_entities, tracked, entity_class_generator + ) + + entry.async_on_unload( + async_dispatcher_connect(hass, router.signal_device_new, _async_router_updated) + ) + + _async_router_updated() + + +@callback +def async_add_new_entities(router, async_add_entities, tracked, entity_class_generator): + """Add new tracker entities from the router.""" + new_tracked = [] + + for mac, device in router.devices.items(): + if mac in tracked: + continue + + new_tracked.extend(entity_class_generator(router, device)) + tracked.add(mac) + + if new_tracked: + async_add_entities(new_tracked, True) + + +class NetgearRouter: + """Representation of a Netgear router.""" + + def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Initialize a Netgear router.""" + self.hass = hass + self.entry = entry + self.entry_id = entry.entry_id + self.unique_id = entry.unique_id + self._host = entry.data.get(CONF_HOST) + self._port = entry.data.get(CONF_PORT) + self._ssl = entry.data.get(CONF_SSL) + self._username = entry.data.get(CONF_USERNAME) + self._password = entry.data[CONF_PASSWORD] + + self._info = None + self.model = None + self.device_name = None + self.firmware_version = None + + self._method_version = 1 + consider_home_int = entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ) + self._consider_home = timedelta(seconds=consider_home_int) + + self._api: Netgear = None + self._attrs = {} + + self.devices = {} + + def _setup(self) -> None: + """Set up a Netgear router sync portion.""" + self._api = get_api( + self._password, + self._host, + self._username, + self._port, + self._ssl, + ) + + self._info = self._api.get_info() + self.device_name = self._info.get("DeviceName", DEFAULT_NAME) + self.model = self._info.get("ModelName") + self.firmware_version = self._info.get("Firmwareversion") + + for model in MODELS_V2: + if self.model.startswith(model): + self._method_version = 2 + + async def async_setup(self) -> None: + """Set up a Netgear router.""" + await self.hass.async_add_executor_job(self._setup) + + # set already known devices to away instead of unavailable + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry(device_registry, self.entry_id) + for device_entry in devices: + if device_entry.via_device_id is None: + continue # do not add the router itself + + device_mac = dict(device_entry.connections).get(dr.CONNECTION_NETWORK_MAC) + self.devices[device_mac] = { + "mac": device_mac, + "name": device_entry.name, + "active": False, + "last_seen": dt_util.utcnow() - timedelta(days=365), + "device_model": None, + "device_type": None, + "type": None, + "link_rate": None, + "signal": None, + "ip": None, + } + + await self.async_update_device_trackers() + self.entry.async_on_unload( + async_track_time_interval( + self.hass, self.async_update_device_trackers, SCAN_INTERVAL + ) + ) + + async_dispatcher_send(self.hass, self.signal_device_new) + + async def async_get_attached_devices(self) -> list: + """Get the devices connected to the router.""" + if self._method_version == 1: + return await self.hass.async_add_executor_job( + self._api.get_attached_devices + ) + + return await self.hass.async_add_executor_job(self._api.get_attached_devices_2) + + async def async_update_device_trackers(self, now=None) -> None: + """Update Netgear devices.""" + new_device = False + ntg_devices = await self.async_get_attached_devices() + now = dt_util.utcnow() + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Netgear scan result: \n%s", ntg_devices) + + for ntg_device in ntg_devices: + device_mac = format_mac(ntg_device.mac) + + if not self.devices.get(device_mac): + new_device = True + + # ntg_device is a namedtuple from the collections module that needs conversion to a dict through ._asdict method + self.devices[device_mac] = ntg_device._asdict() + self.devices[device_mac]["mac"] = device_mac + self.devices[device_mac]["last_seen"] = now + + for device in self.devices.values(): + device["active"] = now - device["last_seen"] <= self._consider_home + + async_dispatcher_send(self.hass, self.signal_device_update) + + if new_device: + _LOGGER.debug("Netgear tracker: new device found") + async_dispatcher_send(self.hass, self.signal_device_new) + + @property + def signal_device_new(self) -> str: + """Event specific per Netgear entry to signal new device.""" + return f"{DOMAIN}-{self._host}-device-new" + + @property + def signal_device_update(self) -> str: + """Event specific per Netgear entry to signal updates in devices.""" + return f"{DOMAIN}-{self._host}-device-update" + + +class NetgearDeviceEntity(Entity): + """Base class for a device connected to a Netgear router.""" + + def __init__(self, router: NetgearRouter, device: dict) -> None: + """Initialize a Netgear device.""" + self._router = router + self._device = device + self._mac = device["mac"] + self._name = self.get_device_name() + self._device_name = self._name + self._unique_id = self._mac + self._active = device["active"] + + def get_device_name(self): + """Return the name of the given device or the MAC if we don't know.""" + name = self._device["name"] + if not name or name == "--": + name = self._mac + + return name + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @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 device_info(self): + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "name": self._device_name, + "model": self._device["device_model"], + "via_device": (DOMAIN, self._router.unique_id), + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_added_to_hass(self): + """Register state update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_device_update, + self.async_update_device, + ) + ) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py new file mode 100644 index 00000000000..62867383d6e --- /dev/null +++ b/homeassistant/components/netgear/sensor.py @@ -0,0 +1,83 @@ +"""Support for Netgear routers.""" +import logging + +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import HomeAssistantType + +from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry + +_LOGGER = logging.getLogger(__name__) + + +SENSOR_TYPES = { + "type": SensorEntityDescription( + key="type", + name="link type", + native_unit_of_measurement=None, + device_class=None, + ), + "link_rate": SensorEntityDescription( + key="link_rate", + name="link rate", + native_unit_of_measurement="Mbps", + device_class=None, + ), + "signal": SensorEntityDescription( + key="signal", + name="signal strength", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), +} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up device tracker for Netgear component.""" + + def generate_sensor_classes(router: NetgearRouter, device: dict): + return [ + NetgearSensorEntity(router, device, attribute) + for attribute in ("type", "link_rate", "signal") + ] + + async_setup_netgear_entry(hass, entry, async_add_entities, generate_sensor_classes) + + +class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): + """Representation of a device connected to a Netgear router.""" + + _attr_entity_registry_enabled_default = False + + def __init__(self, router: NetgearRouter, device: dict, attribute: str) -> None: + """Initialize a Netgear device.""" + super().__init__(router, device) + self._attribute = attribute + self.entity_description = SENSOR_TYPES[self._attribute] + self._name = f"{self.get_device_name()} {self.entity_description.name}" + self._unique_id = f"{self._mac}-{self._attribute}" + self._state = self._device[self._attribute] + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._state + + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + self._device = self._router.devices[self._mac] + self._active = self._device["active"] + if self._device[self._attribute] is not None: + self._state = self._device[self._attribute] + + self.async_write_ha_state() diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json new file mode 100644 index 00000000000..318864291f1 --- /dev/null +++ b/homeassistant/components/netgear/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "Default host: {host}\nDefault port: {port}\nDefault username: {username}", + "data": { + "host": "[%key:common::config_flow::data::host%] (Optional)", + "port": "[%key:common::config_flow::data::port%] (Optional)", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%] (Optional)", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "config": "Connection or login error: please check your configuration" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "description": "Specify optional settings", + "data": { + "consider_home": "Consider home time (seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/ca.json b/homeassistant/components/netgear/translations/ca.json new file mode 100644 index 00000000000..48de8c99684 --- /dev/null +++ b/homeassistant/components/netgear/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "config": "Error de connexi\u00f3 o d'inici de sessi\u00f3: comprova la configuraci\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3 (opcional)", + "password": "Contrasenya", + "port": "Port (opcional)", + "ssl": "Utilitza un certificat SSL", + "username": "Nom d'usuari (opcional)" + }, + "description": "Amfitri\u00f3 predeterminat: {host}\nPort predeterminat: {port}\nNom d'usuari predeterminat: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Temps per considerar 'a casa' (segons)" + }, + "description": "Especifica les configuracions opcional", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/cs.json b/homeassistant/components/netgear/translations/cs.json new file mode 100644 index 00000000000..786cd2229ab --- /dev/null +++ b/homeassistant/components/netgear/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Hostitel (nepovinn\u00fd)", + "password": "Heslo", + "port": "Port (nepovinn\u00fd)", + "ssl": "Pou\u017e\u00edv\u00e1 SSL certifik\u00e1t", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no (nepovinn\u00e9)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/de.json b/homeassistant/components/netgear/translations/de.json new file mode 100644 index 00000000000..d1ee1310cad --- /dev/null +++ b/homeassistant/components/netgear/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "config": "Verbindungs- oder Anmeldefehler: Bitte \u00fcberpr\u00fcfe deine Konfiguration" + }, + "step": { + "user": { + "data": { + "host": "Host (Optional)", + "password": "Passwort", + "port": "Port (Optional)", + "ssl": "Verwendet ein SSL-Zertifikat", + "username": "Benutzername (Optional)" + }, + "description": "Standardhost: {host}\nStandardport: {port}\nStandardbenutzername: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Zu Hause Zeit (Sekunden)" + }, + "description": "Optionale Einstellungen angeben", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/en.json b/homeassistant/components/netgear/translations/en.json new file mode 100644 index 00000000000..f9c2dbf2c91 --- /dev/null +++ b/homeassistant/components/netgear/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "config": "Connection or login error: please check your configuration" + }, + "step": { + "user": { + "data": { + "host": "Host (Optional)", + "password": "Password", + "port": "Port (Optional)", + "ssl": "Uses an SSL certificate", + "username": "Username (Optional)" + }, + "description": "Default host: {host}\nDefault port: {port}\nDefault username: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Consider home time (seconds)" + }, + "description": "Specify optional settings", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/es.json b/homeassistant/components/netgear/translations/es.json new file mode 100644 index 00000000000..57054de1c37 --- /dev/null +++ b/homeassistant/components/netgear/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "host": "Host (Opcional)", + "password": "Contrase\u00f1a", + "port": "Puerto (Opcional)", + "ssl": "Utiliza un certificado SSL", + "username": "Usuario (Opcional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/et.json b/homeassistant/components/netgear/translations/et.json new file mode 100644 index 00000000000..ad100c4b83e --- /dev/null +++ b/homeassistant/components/netgear/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "config": "\u00dchenduse v\u00f5i sisselogimise viga: kontrolli oma s\u00e4tteid" + }, + "step": { + "user": { + "data": { + "host": "Host (valikuline)", + "password": "Salas\u00f5na", + "port": "Port (valikuline)", + "ssl": "Kasutusel on SSL sert", + "username": "Kasutajanimi (valikuline)" + }, + "description": "Vaikimisi host: {host}\nVaikeport: {port}\nVaikimisi kasutajanimi: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Kohaloleku m\u00e4\u00e4ramise aeg (sekundites)" + }, + "description": "Valikuliste s\u00e4tete m\u00e4\u00e4ramine", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/he.json b/homeassistant/components/netgear/translations/he.json new file mode 100644 index 00000000000..f1f42b6c771 --- /dev/null +++ b/homeassistant/components/netgear/translations/he.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "ssl": "\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05d9\u05e9\u05d5\u05e8 SSL", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9 (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + }, + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/hu.json b/homeassistant/components/netgear/translations/hu.json new file mode 100644 index 00000000000..64452c9ef58 --- /dev/null +++ b/homeassistant/components/netgear/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "config": "Csatlakoz\u00e1si vagy bejelentkez\u00e9si hiba: k\u00e9rj\u00fck, ellen\u0151rizze a konfigur\u00e1ci\u00f3t" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm (nem k\u00f6telez\u0151)", + "password": "Jelsz\u00f3", + "port": "Port (nem k\u00f6telez\u0151)", + "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v (nem k\u00f6telez\u0151)" + }, + "description": "Alap\u00e9rtelmezett c\u00edm: {host}\nAlap\u00e9rtelmezett port: {port}\nAlap\u00e9rtelmezett felhaszn\u00e1l\u00f3n\u00e9v: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Otthoni \u00e1llapotnak tekint\u00e9s (m\u00e1sodperc)" + }, + "description": "Opcion\u00e1lis be\u00e1ll\u00edt\u00e1sok megad\u00e1sa", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/id.json b/homeassistant/components/netgear/translations/id.json new file mode 100644 index 00000000000..a6a41a5023f --- /dev/null +++ b/homeassistant/components/netgear/translations/id.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "step": { + "user": { + "data": { + "host": "Host (Opsional)", + "password": "Kata Sandi", + "port": "Port (Opsional)", + "ssl": "Menggunakan sertifikat SSL", + "username": "Nama Pengguna (Opsional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/it.json b/homeassistant/components/netgear/translations/it.json new file mode 100644 index 00000000000..72feece850a --- /dev/null +++ b/homeassistant/components/netgear/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "config": "Errore di connessione o di login: controlla la tua configurazione" + }, + "step": { + "user": { + "data": { + "host": "Host (Facoltativo)", + "password": "Password", + "port": "Porta (Facoltativo)", + "ssl": "Utilizza un certificato SSL", + "username": "Nome utente (Facoltativo)" + }, + "description": "Host predefinito: {host}\nPorta predefinita: {port}\nNome utente predefinito: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Considera il tempo in casa (secondi)" + }, + "description": "Specificare le impostazioni opzionali", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/nl.json b/homeassistant/components/netgear/translations/nl.json new file mode 100644 index 00000000000..22ac348af4e --- /dev/null +++ b/homeassistant/components/netgear/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al ingesteld" + }, + "error": { + "config": "Verbindings- of inlogfout; controleer uw configuratie" + }, + "step": { + "user": { + "data": { + "host": "Host (optioneel)", + "password": "Wachtwoord", + "port": "Poort (optioneel)", + "ssl": "Gebruikt een SSL certificaat", + "username": "Gebruikersnaam (optioneel)" + }, + "description": "Standaard host: {host}\nStandaard poort: {port}\nStandaard gebruikersnaam: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Overweeg thuis tijd (seconden)" + }, + "description": "Optionele instellingen opgeven", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/no.json b/homeassistant/components/netgear/translations/no.json new file mode 100644 index 00000000000..52020ae3824 --- /dev/null +++ b/homeassistant/components/netgear/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "config": "Tilkoblings- eller p\u00e5loggingsfeil: Kontroller konfigurasjonen" + }, + "step": { + "user": { + "data": { + "host": "Vert (valgfritt)", + "password": "Passord", + "port": "Port (valgfritt)", + "ssl": "Bruker et SSL-sertifikat", + "username": "Brukernavn (Valgfritt)" + }, + "description": "Standard vert: {host}\nStandardport: {port}\nStandard brukernavn: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Vurder hjemmetid (sekunder)" + }, + "description": "Spesifiser valgfrie innstillinger", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/pt-BR.json b/homeassistant/components/netgear/translations/pt-BR.json new file mode 100644 index 00000000000..ec18c9a65df --- /dev/null +++ b/homeassistant/components/netgear/translations/pt-BR.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "host": "Host (Opcional)", + "password": "Senha", + "port": "Porta (Opcional)", + "ssl": "Utilize um certificado SSL", + "username": "Usu\u00e1rio (Opcional)" + }, + "description": "Host padr\u00e3o: {host}\n Porta padr\u00e3o: {port}\n Usu\u00e1rio padr\u00e3o: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "description": "Especifique configura\u00e7\u00f5es opcionais", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/ru.json b/homeassistant/components/netgear/translations/ru.json new file mode 100644 index 00000000000..035492a01fe --- /dev/null +++ b/homeassistant/components/netgear/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." + }, + "error": { + "config": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u043b\u0438 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" + }, + "description": "\u0425\u043e\u0441\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: {host}\n\u041f\u043e\u0440\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: {port}\n\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0412\u0440\u0435\u043c\u044f, \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043e\u043c\u0430 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + }, + "description": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/zh-Hant.json b/homeassistant/components/netgear/translations/zh-Hant.json new file mode 100644 index 00000000000..a4978fbb6bc --- /dev/null +++ b/homeassistant/components/netgear/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "config": "\u9023\u7dda\u6216\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u6aa2\u67e5\u60a8\u7684\u8a2d\u5b9a" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef\uff08\u9078\u9805\uff09", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0\uff08\u9078\u9805\uff09", + "ssl": "\u4f7f\u7528 SSL \u8a8d\u8b49", + "username": "\u4f7f\u7528\u8005\u540d\u7a31\uff08\u9078\u9805\uff09" + }, + "description": "\u9810\u8a2d\u4e3b\u6a5f\u7aef\uff1a{host}\n\u9810\u8a2d\u901a\u8a0a\u57e0\uff1a{port}\n\u9810\u8a2d\u4f7f\u7528\u8005\u540d\u7a31\uff1a{username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u5224\u5b9a\u5728\u5bb6\u6642\u9593\uff08\u79d2\uff09" + }, + "description": "\u6307\u5b9a\u9078\u9805\u8a2d\u5b9a", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index a7dffad7084..024075ba2c1 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -1,7 +1,7 @@ """The Network Configuration integration.""" from __future__ import annotations -from ipaddress import IPv4Address, IPv6Address +from ipaddress import IPv4Address, IPv6Address, ip_interface import logging import voluptuous as vol @@ -17,6 +17,7 @@ from .const import ( ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, DOMAIN, + IPV4_BROADCAST_ADDR, NETWORK_CONFIG_SCHEMA, ) from .models import Adapter @@ -75,6 +76,26 @@ def async_only_default_interface_enabled(adapters: list[Adapter]) -> bool: ) +@bind_hass +async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Address]: + """Return a set of broadcast addresses.""" + broadcast_addresses: set[IPv4Address] = {IPv4Address(IPV4_BROADCAST_ADDR)} + adapters = await async_get_adapters(hass) + if async_only_default_interface_enabled(adapters): + return broadcast_addresses + for adapter in adapters: + if not adapter["enabled"]: + continue + for ip_info in adapter["ipv4"]: + interface = ip_interface( + f"{ip_info['address']}/{ip_info['network_prefix']}" + ) + broadcast_addresses.add( + IPv4Address(interface.network.broadcast_address.exploded) + ) + return broadcast_addresses + + 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 8b695a52e13..7e7401251fc 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -17,6 +17,7 @@ DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] MDNS_TARGET_IP: Final = "224.0.0.251" PUBLIC_TARGET_IP: Final = "8.8.8.8" +IPV4_BROADCAST_ADDR: Final = "255.255.255.255" NETWORK_CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/nexia/translations/fr.json b/homeassistant/components/nexia/translations/fr.json index 5cec9b66836..b76672cd017 100644 --- a/homeassistant/components/nexia/translations/fr.json +++ b/homeassistant/components/nexia/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Cette maison Nexia est d\u00e9j\u00e0 configur\u00e9e" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/nfandroidtv/translations/es.json b/homeassistant/components/nfandroidtv/translations/es.json index e99ce545b74..880835cfb1e 100644 --- a/homeassistant/components/nfandroidtv/translations/es.json +++ b/homeassistant/components/nfandroidtv/translations/es.json @@ -1,8 +1,19 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, "step": { "user": { - "description": "Esta integraci\u00f3n requiere la aplicaci\u00f3n de Notificaciones para Android TV.\n\nPara Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPara Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDebe configurar una reserva DHCP en su router (consulte el manual de usuario de su router) o una direcci\u00f3n IP est\u00e1tica en el dispositivo. Si no, el dispositivo acabar\u00e1 por no estar disponible.", + "data": { + "host": "Host", + "name": "Nombre" + }, + "description": "Esta integraci\u00f3n requiere la aplicaci\u00f3n de Notificaciones para Android TV.\n\nPara Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPara Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDebes configurar una reserva DHCP en su router (consulta el manual de usuario de tu router) o una direcci\u00f3n IP est\u00e1tica en el dispositivo. Si no, el dispositivo acabar\u00e1 por no estar disponible.", "title": "Notificaciones para Android TV / Fire TV" } } diff --git a/homeassistant/components/nfandroidtv/translations/hu.json b/homeassistant/components/nfandroidtv/translations/hu.json index e7dea95e4d0..c0dc8d679d6 100644 --- a/homeassistant/components/nfandroidtv/translations/hu.json +++ b/homeassistant/components/nfandroidtv/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "H\u00e1zigazda", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "Ehhez az integr\u00e1ci\u00f3hoz az \u00c9rtes\u00edt\u00e9sek az Android TV alkalmaz\u00e1shoz sz\u00fcks\u00e9ges. \n\nAndroid TV eset\u00e9n: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nA Fire TV eset\u00e9ben: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nBe kell \u00e1ll\u00edtania a DHCP -foglal\u00e1st az \u00fatv\u00e1laszt\u00f3n (l\u00e1sd az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t), vagy egy statikus IP -c\u00edmet az eszk\u00f6z\u00f6n. Ha nem, az eszk\u00f6z v\u00e9g\u00fcl el\u00e9rhetetlenn\u00e9 v\u00e1lik.", diff --git a/homeassistant/components/nfandroidtv/translations/id.json b/homeassistant/components/nfandroidtv/translations/id.json new file mode 100644 index 00000000000..087e25a22ae --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/id.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/strings.json b/homeassistant/components/nightscout/strings.json index 709788c5818..b3b99485587 100644 --- a/homeassistant/components/nightscout/strings.json +++ b/homeassistant/components/nightscout/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Enter your Nightscout server information.", - "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (Optional): Only use if your instance is protected (auth_default_roles != readable).", + "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (optional): Only use if your instance is protected (auth_default_roles != readable).", "data": { "url": "[%key:common::config_flow::data::url%]", "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/homeassistant/components/nightscout/translations/de.json b/homeassistant/components/nightscout/translations/de.json index 21bea5ee877..32bc6653af1 100644 --- a/homeassistant/components/nightscout/translations/de.json +++ b/homeassistant/components/nightscout/translations/de.json @@ -15,7 +15,7 @@ "api_key": "API-Schl\u00fcssel", "url": "URL" }, - "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).", + "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/en.json b/homeassistant/components/nightscout/translations/en.json index d8b4c441283..baec475fc2d 100644 --- a/homeassistant/components/nightscout/translations/en.json +++ b/homeassistant/components/nightscout/translations/en.json @@ -15,7 +15,7 @@ "api_key": "API Key", "url": "URL" }, - "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (Optional): Only use if your instance is protected (auth_default_roles != readable).", + "description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (optional): Only use if your instance is protected (auth_default_roles != readable).", "title": "Enter your Nightscout server information." } } diff --git a/homeassistant/components/nightscout/translations/hu.json b/homeassistant/components/nightscout/translations/hu.json index b3e5a36e172..569a4f3aca9 100644 --- a/homeassistant/components/nightscout/translations/hu.json +++ b/homeassistant/components/nightscout/translations/hu.json @@ -15,7 +15,7 @@ "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).", + "description": "- URL: a nightscout p\u00e9ld\u00e1ny c\u00edme. Pl: https://myhomeassistant.duckdns.org:5423\n - API kulcs (opcion\u00e1lis): Csak akkor haszn\u00e1lja, ha a p\u00e9ld\u00e1nya v\u00e9dett (auth_default_roles != readable).", "title": "Adja meg a Nightscout szerver adatait." } } diff --git a/homeassistant/components/nightscout/translations/id.json b/homeassistant/components/nightscout/translations/id.json index 75496084bc4..147c3131213 100644 --- a/homeassistant/components/nightscout/translations/id.json +++ b/homeassistant/components/nightscout/translations/id.json @@ -15,7 +15,7 @@ "api_key": "Kunci API", "url": "URL" }, - "description": "- URL: alamat instans nightscout Anda, misalnya https://myhomeassistant.duckdns.org:5423\n- Kunci API (Opsional): Hanya gunakan jika instans Anda dilindungi (auth_default_roles != dapat dibaca).", + "description": "- URL: alamat instans nightscout Anda, misalnya https://myhomeassistant.duckdns.org:5423\n- Kunci API (opsional): Hanya gunakan jika instans Anda dilindungi (auth_default_roles != dapat dibaca).", "title": "Masukkan informasi server Nightscout Anda." } } diff --git a/homeassistant/components/nightscout/translations/no.json b/homeassistant/components/nightscout/translations/no.json index d68fe45c684..6a21b13aca7 100644 --- a/homeassistant/components/nightscout/translations/no.json +++ b/homeassistant/components/nightscout/translations/no.json @@ -15,7 +15,7 @@ "api_key": "API-n\u00f8kkel", "url": "URL" }, - "description": "- URL: Adressen til din nattscout-forekomst. F. Eks: https://myhomeassistant.duckdns.org:5423 \n- API-n\u00f8kkel (valgfritt): Bruk bare hvis forekomsten din er beskyttet (auth_default_roles! = readable).", + "description": "- URL: adressen til nightscout -forekomsten din. Dvs: https://myhomeassistant.duckdns.org:5423\n - API -n\u00f8kkel (valgfritt): Bruk bare hvis forekomsten din er beskyttet (auth_default_roles! = Lesbar).", "title": "Skriv inn informasjon om Nightscout-serveren." } } diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json index 55cd28d59fa..89e55cb69d9 100644 --- a/homeassistant/components/nissan_leaf/manifest.json +++ b/homeassistant/components/nissan_leaf/manifest.json @@ -2,7 +2,7 @@ "domain": "nissan_leaf", "name": "Nissan Leaf", "documentation": "https://www.home-assistant.io/integrations/nissan_leaf", - "requirements": ["pycarwings2==2.11"], + "requirements": ["pycarwings2==2.12"], "codeowners": ["@filcole"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index e475afd24c8..b63280951a6 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,8 +1,9 @@ """Support for scanning a network with nmap.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/nmap_tracker/translations/ca.json b/homeassistant/components/nmap_tracker/translations/ca.json index 857772081d8..e72c03cc63a 100644 --- a/homeassistant/components/nmap_tracker/translations/ca.json +++ b/homeassistant/components/nmap_tracker/translations/ca.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Segons d'espera abans de considerar un dispositiu de seguiment com 'a fora' despr\u00e9s de no ser vist.", "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)", diff --git a/homeassistant/components/nmap_tracker/translations/de.json b/homeassistant/components/nmap_tracker/translations/de.json index 729a964059f..48f06bf320b 100644 --- a/homeassistant/components/nmap_tracker/translations/de.json +++ b/homeassistant/components/nmap_tracker/translations/de.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Wartezeit in Sekunden, bis ein Ger\u00e4tetracker als nicht zu Hause markiert wird, nachdem er nicht gesehen wurde.", "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", diff --git a/homeassistant/components/nmap_tracker/translations/el.json b/homeassistant/components/nmap_tracker/translations/el.json new file mode 100644 index 00000000000..74a0f8b1c9c --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/el.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0394\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1 \u03b1\u03bd\u03b1\u03bc\u03bf\u03bd\u03ae\u03c2 \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03b5\u03c0\u03b9\u03c3\u03b7\u03bc\u03b1\u03bd\u03b8\u03b5\u03af \u03bc\u03b9\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03bd\u03c4\u03bf\u03c0\u03b9\u03c3\u03bc\u03bf\u03cd \u03c9\u03c2 \u03cc\u03c7\u03b9 \u03c3\u03c0\u03af\u03c4\u03b9, \u03b1\u03c6\u03bf\u03cd \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b5\u03bc\u03c6\u03b1\u03bd\u03b9\u03c3\u03c4\u03b5\u03af." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index feeea1ff8be..9ded6eae4c2 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -30,7 +30,8 @@ "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap" + "scan_options": "Raw configurable scan options for Nmap", + "track_new_devices": "Track new devices" }, "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)." } diff --git a/homeassistant/components/nmap_tracker/translations/es.json b/homeassistant/components/nmap_tracker/translations/es.json index d5c3d71321f..212b56a9606 100644 --- a/homeassistant/components/nmap_tracker/translations/es.json +++ b/homeassistant/components/nmap_tracker/translations/es.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Segundos de espera hasta que se marca un dispositivo de seguimiento como no en casa despu\u00e9s de no ser visto.", "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", diff --git a/homeassistant/components/nmap_tracker/translations/et.json b/homeassistant/components/nmap_tracker/translations/et.json index 09b46a15889..538f1127448 100644 --- a/homeassistant/components/nmap_tracker/translations/et.json +++ b/homeassistant/components/nmap_tracker/translations/et.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Aeg sekundites mille j\u00e4rel seadme olekuks m\u00e4\u00e4ratakse Eemal peale seadme v\u00f5rgust kadumist.", "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)", diff --git a/homeassistant/components/nmap_tracker/translations/fr.json b/homeassistant/components/nmap_tracker/translations/fr.json index 69d7d58f2e6..59e02ea14cc 100644 --- a/homeassistant/components/nmap_tracker/translations/fr.json +++ b/homeassistant/components/nmap_tracker/translations/fr.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Secondes \u00e0 attendre avant de marquer un tracker d'appareil comme n'\u00e9tant pas \u00e0 la maison apr\u00e8s ne pas avoir \u00e9t\u00e9 vu.", "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", @@ -32,7 +33,7 @@ "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)." + "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)." } } }, diff --git a/homeassistant/components/nmap_tracker/translations/hu.json b/homeassistant/components/nmap_tracker/translations/hu.json index 1b5dc9d029b..7385f12b3df 100644 --- a/homeassistant/components/nmap_tracker/translations/hu.json +++ b/homeassistant/components/nmap_tracker/translations/hu.json @@ -4,7 +4,7 @@ "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" }, "error": { - "invalid_hosts": "\u00c9rv\u00e9nytelen gazdag\u00e9pek" + "invalid_hosts": "\u00c9rv\u00e9nytelen c\u00edmek" }, "step": { "user": { @@ -14,17 +14,18 @@ "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." + "description": "\u00c1ll\u00edtsa be a hogy milyen c\u00edmeket szkenneljen az Nmap. A h\u00e1l\u00f3zati c\u00edm lehet IP-c\u00edm (pl. 192.168.1.1), IP-h\u00e1l\u00f3zat (pl. 192.168.0.0/24) vagy IP-tartom\u00e1ny (pl. 192.168.1.0-32)." } } }, "options": { "error": { - "invalid_hosts": "\u00c9rv\u00e9nytelen gazdag\u00e9p" + "invalid_hosts": "\u00c9rv\u00e9nytelen c\u00edmek" }, "step": { "init": { "data": { + "consider_home": "K\u00e9sleltet\u00e9s (m\u00e1sodpercben), am\u00edg az eszk\u00f6zk\u00f6vet\u0151 elveszt\u00e9se ut\u00e1n aktiv\u00e1l\u00f3djon a funcki\u00f3.", "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", @@ -32,7 +33,7 @@ "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." + "description": "\u00c1ll\u00edtsa be a hogy milyen c\u00edmeket szkenneljen az Nmap. A h\u00e1l\u00f3zati c\u00edm lehet IP-c\u00edm (pl. 192.168.1.1), IP-h\u00e1l\u00f3zat (pl. 192.168.0.0/24) vagy IP-tartom\u00e1ny (pl. 192.168.1.0-32)." } } }, diff --git a/homeassistant/components/nmap_tracker/translations/id.json b/homeassistant/components/nmap_tracker/translations/id.json index d36ba84e8ac..6c06e815565 100644 --- a/homeassistant/components/nmap_tracker/translations/id.json +++ b/homeassistant/components/nmap_tracker/translations/id.json @@ -8,6 +8,7 @@ "step": { "init": { "data": { + "interval_seconds": "Interval pindai", "track_new_devices": "Lacak perangkat baru" } } diff --git a/homeassistant/components/nmap_tracker/translations/it.json b/homeassistant/components/nmap_tracker/translations/it.json index 921d131c3bb..8a7de165778 100644 --- a/homeassistant/components/nmap_tracker/translations/it.json +++ b/homeassistant/components/nmap_tracker/translations/it.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Secondi di attesa per contrassegnare un localizzatore di dispositivi come non in casa dopo non essere stato visto.", "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", diff --git a/homeassistant/components/nmap_tracker/translations/nl.json b/homeassistant/components/nmap_tracker/translations/nl.json index e9675dfc328..8385aca1ffe 100644 --- a/homeassistant/components/nmap_tracker/translations/nl.json +++ b/homeassistant/components/nmap_tracker/translations/nl.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Aantal seconden wachten tot het markeren van een apparaattracker als niet thuis nadat hij niet is gezien.", "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", diff --git a/homeassistant/components/nmap_tracker/translations/no.json b/homeassistant/components/nmap_tracker/translations/no.json index 03a241bc3a2..acd25607c4f 100644 --- a/homeassistant/components/nmap_tracker/translations/no.json +++ b/homeassistant/components/nmap_tracker/translations/no.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Sekunder \u00e5 vente til du merker en enhetssporing som ikke hjemme etter at den ikke er blitt sett.", "exclude": "Nettverksadresser (kommaseparert) for \u00e5 ekskludere fra skanning", "home_interval": "Minimum antall minutter mellom skanninger av aktive enheter (lagre batteri)", "hosts": "Nettverksadresser (kommaseparert) for \u00e5 skanne", diff --git a/homeassistant/components/nmap_tracker/translations/ru.json b/homeassistant/components/nmap_tracker/translations/ru.json index 1a790358c73..ba143e20d01 100644 --- a/homeassistant/components/nmap_tracker/translations/ru.json +++ b/homeassistant/components/nmap_tracker/translations/ru.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "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)", diff --git a/homeassistant/components/nmap_tracker/translations/th.json b/homeassistant/components/nmap_tracker/translations/th.json new file mode 100644 index 00000000000..f5e99824ffd --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/th.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u0e42\u0e1b\u0e23\u0e14\u0e23\u0e2d\u0e2a\u0e31\u0e01\u0e04\u0e23\u0e39\u0e48 \u0e08\u0e19\u0e01\u0e27\u0e48\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e15\u0e34\u0e14\u0e15\u0e32\u0e21 \u0e17\u0e33\u0e40\u0e04\u0e23\u0e37\u0e48\u0e2d\u0e07\u0e2b\u0e21\u0e32\u0e22\u0e27\u0e48\u0e32\u0e44\u0e21\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19 \u0e2b\u0e25\u0e31\u0e07\u0e08\u0e32\u0e01\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e21\u0e35\u0e43\u0e04\u0e23\u0e40\u0e2b\u0e47\u0e19" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/zh-Hans.json b/homeassistant/components/nmap_tracker/translations/zh-Hans.json index e0ca0563b7a..5b1be2f497d 100644 --- a/homeassistant/components/nmap_tracker/translations/zh-Hans.json +++ b/homeassistant/components/nmap_tracker/translations/zh-Hans.json @@ -22,10 +22,13 @@ "step": { "init": { "data": { + "consider_home": "\u7b49\u5f85\u591a\u5c11\u79d2\u540e\u5219\u5224\u5b9a\u8bbe\u5907\u79bb\u5f00", "exclude": "\u4ece\u626b\u63cf\u4e2d\u6392\u9664\u7684\u7f51\u7edc\u5730\u5740\uff08\u4ee5\u9017\u53f7\u5206\u9694\uff09", "home_interval": "\u626b\u63cf\u8bbe\u5907\u7684\u6700\u5c0f\u95f4\u9694\u5206\u949f\u6570\uff08\u7528\u4e8e\u8282\u7701\u7535\u91cf\uff09", "hosts": "\u8981\u626b\u63cf\u7684\u7f51\u7edc\u5730\u5740\uff08\u4ee5\u9017\u53f7\u5206\u9694\uff09", - "scan_options": "Nmap \u7684\u539f\u59cb\u53ef\u914d\u7f6e\u626b\u63cf\u9009\u9879" + "interval_seconds": "\u626b\u63cf\u95f4\u9694\uff08\u79d2\uff09", + "scan_options": "Nmap \u7684\u539f\u59cb\u53ef\u914d\u7f6e\u626b\u63cf\u9009\u9879", + "track_new_devices": "\u8ddf\u8e2a\u65b0\u8bbe\u5907" }, "description": "\u914d\u7f6e\u901a\u8fc7 Nmap \u626b\u63cf\u7684\u4e3b\u673a\u3002\u7f51\u7edc\u5730\u5740\u548c\u6392\u9664\u9879\u53ef\u4ee5\u662f IP \u5730\u5740 (192.168.1.1)\u3001IP \u5730\u5740\u5757 (192.168.0.0/24) \u6216 IP \u8303\u56f4 (192.168.1.0-32)\u3002" } diff --git a/homeassistant/components/nmap_tracker/translations/zh-Hant.json b/homeassistant/components/nmap_tracker/translations/zh-Hant.json index a2c396fdec0..65b0b7afed4 100644 --- a/homeassistant/components/nmap_tracker/translations/zh-Hant.json +++ b/homeassistant/components/nmap_tracker/translations/zh-Hant.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "\u8996\u70ba\u8ffd\u8e64\u88dd\u7f6e\u96e2\u958b\u7684\u7b49\u5019\u79d2\u6578", "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", diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 00047f0a32b..f3c2778bc59 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -10,9 +10,9 @@ import voluptuous as vol import homeassistant.components.persistent_notification as pn from homeassistant.const import CONF_DESCRIPTION, CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import config_per_platform, discovery, template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.loader import async_get_integration, bind_hass @@ -68,6 +68,19 @@ PERSISTENT_NOTIFICATION_SERVICE_SCHEMA = vol.Schema( ) +@callback +def _check_templates_warn(hass: HomeAssistant, tpl: template.Template) -> None: + """Warn user that passing templates to notify service is deprecated.""" + if tpl.is_static or hass.data.get("notify_template_warned"): + return + + hass.data["notify_template_warned"] = True + _LOGGER.warning( + "Passing templates to notify service is deprecated and will be removed in 2021.12. " + "Automations and scripts handle templates automatically" + ) + + @bind_hass async def async_reload(hass: HomeAssistant, integration_name: str) -> None: """Register notify services for an integration.""" @@ -144,6 +157,7 @@ class BaseNotificationService: title = service.data.get(ATTR_TITLE) if title: + _check_templates_warn(self.hass, title) title.hass = self.hass kwargs[ATTR_TITLE] = title.async_render(parse_result=False) @@ -152,6 +166,7 @@ class BaseNotificationService: elif service.data.get(ATTR_TARGET) is not None: kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) + _check_templates_warn(self.hass, message) message.hass = self.hass kwargs[ATTR_MESSAGE] = message.async_render(parse_result=False) kwargs[ATTR_DATA] = service.data.get(ATTR_DATA) @@ -261,10 +276,12 @@ async def async_setup(hass, config): payload = {} message = service.data[ATTR_MESSAGE] message.hass = hass + _check_templates_warn(hass, message) payload[ATTR_MESSAGE] = message.async_render(parse_result=False) title = service.data.get(ATTR_TITLE) if title: + _check_templates_warn(hass, title) title.hass = hass payload[ATTR_TITLE] = title.async_render(parse_result=False) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index c06a08560e9..c9f4131be1a 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -11,7 +11,7 @@ from aionotion.errors import InvalidCredentialsError, NotionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, @@ -52,12 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = await async_get_client( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) - except InvalidCredentialsError: - LOGGER.error("Invalid username and/or password") - return False + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed("Invalid username and/or password") from err except NotionError as err: - LOGGER.error("Config entry failed: %s", err) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Config entry failed to load") from err async def async_update() -> dict[str, dict[str, Any]]: """Get the latest data from the Notion API.""" @@ -70,14 +68,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: results = await asyncio.gather(*tasks.values(), return_exceptions=True) for attr, result in zip(tasks, results): + if isinstance(result, InvalidCredentialsError): + raise ConfigEntryAuthFailed( + "Invalid username and/or password" + ) from result if isinstance(result, NotionError): raise UpdateFailed( f"There was a Notion error while updating {attr}: {result}" - ) + ) from result if isinstance(result, Exception): raise UpdateFailed( f"There was an unknown error while updating {attr}: {result}" - ) + ) from result for item in result: if attr == "bridges" and item["id"] not in data["bridges"]: diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 15c5877ae77..bfd90010a94 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -1,6 +1,9 @@ """Support for Notion binary sensors.""" from __future__ import annotations +from dataclasses import dataclass +from typing import Literal + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_CONNECTIVITY, @@ -33,56 +36,81 @@ from .const import ( SENSOR_WINDOW_HINGED_VERTICAL, ) + +@dataclass +class NotionBinarySensorDescriptionMixin: + """Define an entity description mixin for binary and regular sensors.""" + + on_state: Literal["alarm", "critical", "leak", "not_missing", "open"] + + +@dataclass +class NotionBinarySensorDescription( + BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin +): + """Describe a Notion binary sensor.""" + + BINARY_SENSOR_DESCRIPTIONS = ( - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_BATTERY, name="Low Battery", device_class=DEVICE_CLASS_BATTERY, + on_state="critical", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_DOOR, name="Door", device_class=DEVICE_CLASS_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_GARAGE_DOOR, name="Garage Door", device_class=DEVICE_CLASS_GARAGE_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_LEAK, name="Leak Detector", device_class=DEVICE_CLASS_MOISTURE, + on_state="leak", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_MISSING, name="Missing", device_class=DEVICE_CLASS_CONNECTIVITY, + on_state="not_missing", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_SAFE, name="Safe", device_class=DEVICE_CLASS_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_SLIDING, name="Sliding Door/Window", device_class=DEVICE_CLASS_DOOR, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_SMOKE_CO, name="Smoke/Carbon Monoxide Detector", device_class=DEVICE_CLASS_SMOKE, + on_state="alarm", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_WINDOW_HINGED_HORIZONTAL, name="Hinged Window", device_class=DEVICE_CLASS_WINDOW, + on_state="open", ), - BinarySensorEntityDescription( + NotionBinarySensorDescription( key=SENSOR_WINDOW_HINGED_VERTICAL, name="Hinged Window", device_class=DEVICE_CLASS_WINDOW, + on_state="open", ), ) @@ -114,6 +142,8 @@ async def async_setup_entry( class NotionBinarySensor(NotionEntity, BinarySensorEntity): """Define a Notion sensor.""" + entity_description: NotionBinarySensorDescription + @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" @@ -127,20 +157,4 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): LOGGER.warning("Unknown data payload: %s", task["status"]) state = None - if task["task_type"] == SENSOR_BATTERY: - self._attr_is_on = state == "critical" - elif task["task_type"] in ( - SENSOR_DOOR, - SENSOR_GARAGE_DOOR, - SENSOR_SAFE, - SENSOR_SLIDING, - SENSOR_WINDOW_HINGED_HORIZONTAL, - SENSOR_WINDOW_HINGED_VERTICAL, - ): - 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" + self._attr_is_on = self.entity_description.on_state == state diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index ad6d8eb9519..84fe69eb61a 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,16 +1,31 @@ """Config flow to configure the Notion integration.""" from __future__ import annotations +from typing import TYPE_CHECKING, Any + from aionotion import async_get_client -from aionotion.errors import NotionError +from aionotion.errors import InvalidCredentialsError, 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 homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DOMAIN, LOGGER + +AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) +RE_AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -20,33 +35,77 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self.data_schema = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ) + self._password: str | None = None + self._username: str | None = 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_verify(self, step_id: str, schema: vol.Schema) -> FlowResult: + """Attempt to authenticate the provided credentials.""" + if TYPE_CHECKING: + assert self._username + assert self._password + + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + await async_get_client(self._username, self._password, session=session) + except InvalidCredentialsError: + return self.async_show_form( + step_id=step_id, + data_schema=schema, + errors={"base": "invalid_auth"}, + description_placeholders={CONF_USERNAME: self._username}, + ) + except NotionError as err: + LOGGER.error("Unknown Notion error: %s", err) + return self.async_show_form( + step_id=step_id, + data_schema=schema, + errors={"base": "unknown"}, + description_placeholders={CONF_USERNAME: self._username}, + ) + + data = {CONF_USERNAME: self._username, CONF_PASSWORD: self._password} + + if existing_entry := await self.async_set_unique_id(self._username): + self.hass.config_entries.async_update_entry(existing_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=self._username, data=data) + + async def async_step_reauth(self, config: ConfigType) -> FlowResult: + """Handle configuration by re-auth.""" + self._username = config[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + 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( + step_id="reauth_confirm", + data_schema=RE_AUTH_SCHEMA, + description_placeholders={CONF_USERNAME: self._username}, + ) + + self._password = user_input[CONF_PASSWORD] + + return await self._async_verify("reauth_confirm", RE_AUTH_SCHEMA) 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() + return self.async_show_form(step_id="user", data_schema=AUTH_SCHEMA) await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - session = aiohttp_client.async_get_clientsession(self.hass) + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] - try: - await async_get_client( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=session - ) - except NotionError: - return await self._show_form({"base": "invalid_auth"}) - - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + return await self._async_verify("user", AUTH_SCHEMA) diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 803cfce3360..204074ed884 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,5 +1,9 @@ """Support for Notion sensors.""" -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback @@ -14,6 +18,7 @@ SENSOR_DESCRIPTIONS = ( name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 401f0095e30..49721568ff2 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please re-enter the password for {username}.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "title": "Fill in your information", "data": { @@ -11,10 +18,11 @@ }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_devices": "No devices found in account" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/notion/translations/ca.json b/homeassistant/components/notion/translations/ca.json index 5d89413d36f..51ca461f854 100644 --- a/homeassistant/components/notion/translations/ca.json +++ b/homeassistant/components/notion/translations/ca.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "no_devices": "No s'han trobat dispositius al compte" + "no_devices": "No s'han trobat dispositius al compte", + "unknown": "Error inesperat" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Torna a introduir la contrasenya de {username}.", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "password": "Contrasenya", diff --git a/homeassistant/components/notion/translations/de.json b/homeassistant/components/notion/translations/de.json index 0b421911aa7..59ab1fdc1be 100644 --- a/homeassistant/components/notion/translations/de.json +++ b/homeassistant/components/notion/translations/de.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Konto wurde bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung", - "no_devices": "Keine Ger\u00e4te im Konto gefunden" + "no_devices": "Keine Ger\u00e4te im Konto gefunden", + "unknown": "Unerwarteter Fehler" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte gib das Passwort f\u00fcr {username} erneut ein.", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "password": "Passwort", diff --git a/homeassistant/components/notion/translations/en.json b/homeassistant/components/notion/translations/en.json index a31befc0e95..afd58a4d404 100644 --- a/homeassistant/components/notion/translations/en.json +++ b/homeassistant/components/notion/translations/en.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication", - "no_devices": "No devices found in account" + "no_devices": "No devices found in account", + "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please re-enter the password for {username}.", + "title": "Reauthenticate Integration" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/notion/translations/et.json b/homeassistant/components/notion/translations/et.json index a377f1e69ab..7639901201d 100644 --- a/homeassistant/components/notion/translations/et.json +++ b/homeassistant/components/notion/translations/et.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Konto on juba seadistatud" + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_auth": "Tuvastamise viga", - "no_devices": "Kontolt ei leitud \u00fchtegi seadet" + "no_devices": "Kontolt ei leitud \u00fchtegi seadet", + "unknown": "Ootamatu t\u00f5rge" }, "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Sisesta uuesti {username} salas\u00f5na.", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "password": "Salas\u00f5na", diff --git a/homeassistant/components/notion/translations/fr.json b/homeassistant/components/notion/translations/fr.json index c8f82077a0a..5979af3cf04 100644 --- a/homeassistant/components/notion/translations/fr.json +++ b/homeassistant/components/notion/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce nom d'utilisateur est d\u00e9j\u00e0 utilis\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_auth": "Authentification invalide", diff --git a/homeassistant/components/notion/translations/he.json b/homeassistant/components/notion/translations/he.json index 1a397f894cf..159db09e3b3 100644 --- a/homeassistant/components/notion/translations/he.json +++ b/homeassistant/components/notion/translations/he.json @@ -1,12 +1,20 @@ { "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" + "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" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/notion/translations/hu.json b/homeassistant/components/notion/translations/hu.json index b4d57f83bb3..43f4f1f914c 100644 --- a/homeassistant/components/notion/translations/hu.json +++ b/homeassistant/components/notion/translations/hu.json @@ -1,13 +1,22 @@ { "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 \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "no_devices": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a fi\u00f3kban" + "no_devices": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a fi\u00f3kban", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rem, adja meg ism\u00e9t {username} jelszav\u00e1t", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/notion/translations/it.json b/homeassistant/components/notion/translations/it.json index 3304d2b395a..69d2294394b 100644 --- a/homeassistant/components/notion/translations/it.json +++ b/homeassistant/components/notion/translations/it.json @@ -1,13 +1,22 @@ { "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", - "no_devices": "Nessun dispositivo trovato nell'account" + "no_devices": "Nessun dispositivo trovato nell'account", + "unknown": "Errore imprevisto" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Inserisci nuovamente la password per {username}.", + "title": "Autenticare nuovamente l'integrazione" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/notion/translations/nl.json b/homeassistant/components/notion/translations/nl.json index acb42046c90..81da85f6240 100644 --- a/homeassistant/components/notion/translations/nl.json +++ b/homeassistant/components/notion/translations/nl.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Account is al geconfigureerd" + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "invalid_auth": "Ongeldige authenticatie", - "no_devices": "Geen apparaten gevonden in account" + "no_devices": "Geen apparaten gevonden in account", + "unknown": "Onverwachte fout" }, "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord voor {username} opnieuw in.", + "title": "Verifieer de integratie opnieuw" + }, "user": { "data": { "password": "Wachtwoord", diff --git a/homeassistant/components/notion/translations/no.json b/homeassistant/components/notion/translations/no.json index c1d8a1d17b5..0bbbeb9c0dd 100644 --- a/homeassistant/components/notion/translations/no.json +++ b/homeassistant/components/notion/translations/no.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Kontoen er allerede konfigurert" + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", - "no_devices": "Ingen enheter funnet i kontoen" + "no_devices": "Ingen enheter funnet i kontoen", + "unknown": "Uventet feil" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Skriv inn passordet for {username} p\u00e5 nytt.", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "password": "Passord", diff --git a/homeassistant/components/notion/translations/ru.json b/homeassistant/components/notion/translations/ru.json index 4b9a45bbf3f..bebd8a66e0e 100644 --- a/homeassistant/components/notion/translations/ru.json +++ b/homeassistant/components/notion/translations/ru.json @@ -1,13 +1,22 @@ { "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." + "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.", + "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": { "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e." + "no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e.", + "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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f {username}.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/notion/translations/zh-Hant.json b/homeassistant/components/notion/translations/zh-Hant.json index 865bd1dbd08..951ec07c8ad 100644 --- a/homeassistant/components/notion/translations/zh-Hant.json +++ b/homeassistant/components/notion/translations/zh-Hant.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e" + "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u8f38\u5165 {username} \u5bc6\u78bc\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "password": "\u5bc6\u78bc", diff --git a/homeassistant/components/nuheat/translations/de.json b/homeassistant/components/nuheat/translations/de.json index 0ab69dd4557..682867d5404 100644 --- a/homeassistant/components/nuheat/translations/de.json +++ b/homeassistant/components/nuheat/translations/de.json @@ -16,7 +16,7 @@ "serial_number": "Seriennummer des Thermostats.", "username": "Benutzername" }, - "description": "Du musst die numerische Seriennummer oder ID deines Thermostats erhalten, indem du dich bei https://MyNuHeat.com anmeldest und deine Thermostate ausw\u00e4hlst.", + "description": "Du musst die numerische Seriennummer oder ID deines Thermostats erhalten, indem du dich bei https://MyNuHeat.com anmeldest und dein(e) Thermostat(e) ausw\u00e4hlst.", "title": "Stelle eine Verbindung zu NuHeat her" } } diff --git a/homeassistant/components/nuheat/translations/fr.json b/homeassistant/components/nuheat/translations/fr.json index f0e912805ed..6c50dae47d5 100644 --- a/homeassistant/components/nuheat/translations/fr.json +++ b/homeassistant/components/nuheat/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Le thermostat est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "invalid_thermostat": "Le num\u00e9ro de s\u00e9rie du thermostat n'est pas valide.", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/nuki/translations/fr.json b/homeassistant/components/nuki/translations/fr.json index 248acf70133..360e374888c 100644 --- a/homeassistant/components/nuki/translations/fr.json +++ b/homeassistant/components/nuki/translations/fr.json @@ -4,8 +4,8 @@ "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide ", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { @@ -14,13 +14,13 @@ "token": "Jeton d'acc\u00e8s" }, "description": "L'int\u00e9gration Nuki doit s'authentifier de nouveau avec votre pont.", - "title": "R\u00e9authentifier l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { - "host": "Hote", + "host": "H\u00f4te", "port": "Port", - "token": "jeton d'acc\u00e8s" + "token": "Jeton d'acc\u00e8s" } } } diff --git a/homeassistant/components/nuki/translations/hu.json b/homeassistant/components/nuki/translations/hu.json index 7a0b6b6159e..a5da7700b6f 100644 --- a/homeassistant/components/nuki/translations/hu.json +++ b/homeassistant/components/nuki/translations/hu.json @@ -18,7 +18,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "token": "Hozz\u00e1f\u00e9r\u00e9si token" } diff --git a/homeassistant/components/number/translations/zh-Hans.json b/homeassistant/components/number/translations/zh-Hans.json index de9720ed77a..de50170743e 100644 --- a/homeassistant/components/number/translations/zh-Hans.json +++ b/homeassistant/components/number/translations/zh-Hans.json @@ -3,5 +3,6 @@ "action_type": { "set_value": "\u8bbe\u7f6e {entity_name} \u7684\u503c" } - } + }, + "title": "\u6570\u503c\u8f93\u5165\u5668" } \ No newline at end of file diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index a180c2224f7..3861c608631 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -453,6 +453,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), + "watts": SensorEntityDescription( + key="watts", + name="Watts", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), } STATE_TYPES = { diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 995032eb0fd..5c965274eae 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.nut import PyNUTData from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import ATTR_STATE, CONF_RESOURCES, STATE_UNKNOWN +from homeassistant.const import CONF_RESOURCES, STATE_UNKNOWN from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -133,11 +133,6 @@ class NUTSensor(CoordinatorEntity, SensorEntity): return _format_display_state(self._data.status) return self._data.status.get(self.entity_description.key) - @property - def extra_state_attributes(self): - """Return the sensor attributes.""" - return {ATTR_STATE: _format_display_state(self._data.status)} - def _format_display_state(status): """Return UPS display state.""" diff --git a/homeassistant/components/nut/translations/fr.json b/homeassistant/components/nut/translations/fr.json index 35739689425..3e1b345a8f1 100644 --- a/homeassistant/components/nut/translations/fr.json +++ b/homeassistant/components/nut/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "username": "Nom d'utilisateur" diff --git a/homeassistant/components/nut/translations/hu.json b/homeassistant/components/nut/translations/hu.json index bfc8e01c11a..aa8f7c37105 100644 --- a/homeassistant/components/nut/translations/hu.json +++ b/homeassistant/components/nut/translations/hu.json @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 0e00c848970..318ba687d30 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -1,10 +1,9 @@ """The National Weather Service integration.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import datetime import logging -from typing import Callable from pynws import SimpleNWS diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 32018bc40bb..1bef625eaf5 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -4,7 +4,10 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, @@ -113,6 +116,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Dew Point", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -121,6 +125,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Temperature", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -129,6 +134,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Chill", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -137,6 +143,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Heat Index", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), @@ -145,6 +152,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Relative Humidity", icon=None, device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, unit_convert=PERCENTAGE, ), @@ -153,6 +161,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Speed", icon="mdi:weather-windy", device_class=None, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), @@ -161,6 +170,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Gust", icon="mdi:weather-windy", device_class=None, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), @@ -169,6 +179,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Direction", icon="mdi:compass-rose", device_class=None, + state_class=None, # statistics currently doesn't handle circular statistics native_unit_of_measurement=DEGREE, unit_convert=DEGREE, ), @@ -177,6 +188,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Barometric Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), @@ -185,6 +197,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Sea Level Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), @@ -193,6 +206,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Visibility", icon="mdi:eye", device_class=None, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=LENGTH_METERS, unit_convert=LENGTH_MILES, ), diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index d1e7158ab20..30b00fde15a 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -3,7 +3,7 @@ "name": "National Weather Service (NWS)", "documentation": "https://www.home-assistant.io/integrations/nws", "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==1.3.0"], + "requirements": ["pynws==1.3.1"], "quality_scale": "platinum", "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 85b60ffd475..4be99f95c19 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -55,6 +55,7 @@ class NWSSensor(CoordinatorEntity, SensorEntity): """An NWS Sensor Entity.""" entity_description: NWSSensorEntityDescription + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} def __init__( self, @@ -95,11 +96,6 @@ class NWSSensor(CoordinatorEntity, SensorEntity): return round(value) return value - @property - def device_state_attributes(self): - """Return the attribution.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property def unique_id(self): """Return a unique_id for this entity.""" diff --git a/homeassistant/components/nws/translations/fr.json b/homeassistant/components/nws/translations/fr.json index 568179cf9fa..f88444c440f 100644 --- a/homeassistant/components/nws/translations/fr.json +++ b/homeassistant/components/nws/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/nws/translations/hu.json b/homeassistant/components/nws/translations/hu.json index ec9bf3f4988..4533733e866 100644 --- a/homeassistant/components/nws/translations/hu.json +++ b/homeassistant/components/nws/translations/hu.json @@ -16,7 +16,7 @@ "station": "METAR \u00e1llom\u00e1s k\u00f3dja" }, "description": "Ha a METAR \u00e1llom\u00e1s k\u00f3dja nincs megadva, a sz\u00e9less\u00e9gi \u00e9s hossz\u00fas\u00e1gi fokokat haszn\u00e1lja a legk\u00f6zelebbi \u00e1llom\u00e1s megkeres\u00e9s\u00e9hez. Egyel\u0151re az API-kulcs b\u00e1rmi lehet. Javasoljuk, hogy \u00e9rv\u00e9nyes e -mail c\u00edmet haszn\u00e1ljon.", - "title": "Csatlakozzon az National Weather Service-hez" + "title": "Csatlakoz\u00e1s az National Weather Service-hez" } } } diff --git a/homeassistant/components/nzbget/translations/fr.json b/homeassistant/components/nzbget/translations/fr.json index 4ccc080fbcc..15420989501 100644 --- a/homeassistant/components/nzbget/translations/fr.json +++ b/homeassistant/components/nzbget/translations/fr.json @@ -15,9 +15,9 @@ "name": "Nom", "password": "Mot de passe", "port": "Port", - "ssl": "NZBGet utilise un certificat SSL", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "NZBGet utilise un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "title": "Se connecter \u00e0 NZBGet" } diff --git a/homeassistant/components/nzbget/translations/hu.json b/homeassistant/components/nzbget/translations/hu.json index 829fa03fe8e..6db44f83c28 100644 --- a/homeassistant/components/nzbget/translations/hu.json +++ b/homeassistant/components/nzbget/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/nzbget/translations/id.json b/homeassistant/components/nzbget/translations/id.json index af096f4ef5f..585d50dc2f0 100644 --- a/homeassistant/components/nzbget/translations/id.json +++ b/homeassistant/components/nzbget/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "NZBGet: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index dec80642845..61a99d345ff 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -1,6 +1,8 @@ """Onboarding views.""" import asyncio +from http import HTTPStatus +from aiohttp.web_exceptions import HTTPUnauthorized import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN @@ -8,8 +10,8 @@ from homeassistant.components.auth import indieauth from homeassistant.components.http.const import KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN from homeassistant.core import callback +from homeassistant.helpers.system_info import async_get_system_info from .const import ( DEFAULT_AREAS, @@ -25,6 +27,7 @@ from .const import ( async def async_setup(hass, data, store): """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) @@ -50,6 +53,27 @@ class OnboardingView(HomeAssistantView): ) +class InstallationTypeOnboardingView(HomeAssistantView): + """Return the installation type during onboarding.""" + + requires_auth = False + url = "/api/onboarding/installation_type" + name = "api:onboarding:installation_type" + + def __init__(self, data): + """Initialize the onboarding installation type view.""" + self._data = data + + async def get(self, request): + """Return the onboarding status.""" + if self._data["done"]: + raise HTTPUnauthorized() + + hass = request.app["hass"] + info = await async_get_system_info(hass) + return self.json({"installation_type": info["installation_type"]}) + + class _BaseOnboardingView(HomeAssistantView): """Base class for onboarding.""" @@ -100,7 +124,7 @@ class UserOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): - return self.json_message("User step already done", HTTP_FORBIDDEN) + return self.json_message("User step already done", HTTPStatus.FORBIDDEN) provider = _async_get_hass_provider(hass) await provider.async_initialize() @@ -155,7 +179,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): return self.json_message( - "Core config step already done", HTTP_FORBIDDEN + "Core config step already done", HTTPStatus.FORBIDDEN ) await self._async_mark_done(hass) @@ -193,7 +217,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): return self.json_message( - "Integration step already done", HTTP_FORBIDDEN + "Integration step already done", HTTPStatus.FORBIDDEN ) await self._async_mark_done(hass) @@ -203,13 +227,13 @@ class IntegrationOnboardingView(_BaseOnboardingView): request.app["hass"], data["client_id"], data["redirect_uri"] ): return self.json_message( - "invalid client id or redirect uri", HTTP_BAD_REQUEST + "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) refresh_token = await hass.auth.async_get_refresh_token(refresh_token_id) if refresh_token is None or refresh_token.credential is None: return self.json_message( - "Credentials for user not available", HTTP_FORBIDDEN + "Credentials for user not available", HTTPStatus.FORBIDDEN ) # Return authorization code so we can redirect user and log them in @@ -233,7 +257,7 @@ class AnalyticsOnboardingView(_BaseOnboardingView): async with self._lock: if self._async_is_done(): return self.json_message( - "Analytics config step already done", HTTP_FORBIDDEN + "Analytics config step already done", HTTPStatus.FORBIDDEN ) await self._async_mark_done(hass) diff --git a/homeassistant/components/ondilo_ico/translations/hu.json b/homeassistant/components/ondilo_ico/translations/hu.json index cae1f6d20c0..a6979721779 100644 --- a/homeassistant/components/ondilo_ico/translations/hu.json +++ b/homeassistant/components/ondilo_ico/translations/hu.json @@ -2,7 +2,7 @@ "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\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index ff2ee55d0bd..0b78988f7e1 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -20,7 +20,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .const import ( + CONF_TYPE_OWSERVER, + DEVICE_KEYS_0_7, + DEVICE_KEYS_A_B, + DOMAIN, + READ_MODE_BOOL, +) from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub @@ -33,69 +39,23 @@ class OneWireBinarySensorEntityDescription( DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { - "12": ( + "12": tuple( OneWireBinarySensorEntityDescription( - key="sensed.A", + key=f"sensed.{id}", entity_registry_enabled_default=False, - name="Sensed A", + name=f"Sensed {id}", read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.B", - entity_registry_enabled_default=False, - name="Sensed B", - read_mode=READ_MODE_BOOL, - ), + ) + for id in DEVICE_KEYS_A_B ), - "29": ( + "29": tuple( OneWireBinarySensorEntityDescription( - key="sensed.0", + key=f"sensed.{id}", entity_registry_enabled_default=False, - name="Sensed 0", + name=f"Sensed {id}", read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.1", - entity_registry_enabled_default=False, - name="Sensed 1", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.2", - entity_registry_enabled_default=False, - name="Sensed 2", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.3", - entity_registry_enabled_default=False, - name="Sensed 3", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.4", - entity_registry_enabled_default=False, - name="Sensed 4", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.5", - entity_registry_enabled_default=False, - name="Sensed 5", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.6", - entity_registry_enabled_default=False, - name="Sensed 6", - read_mode=READ_MODE_BOOL, - ), - OneWireBinarySensorEntityDescription( - key="sensed.7", - entity_registry_enabled_default=False, - name="Sensed 7", - read_mode=READ_MODE_BOOL, - ), + ) + for id in DEVICE_KEYS_0_7 ), } diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 4d758146aff..54bfc686459 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -17,6 +17,9 @@ DEFAULT_SYSBUS_MOUNT_DIR = "/sys/bus/w1/devices/" DOMAIN = "onewire" +DEVICE_KEYS_0_7 = range(8) +DEVICE_KEYS_A_B = ("A", "B") + PRESSURE_CBAR = "cbar" READ_MODE_BOOL = "bool" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 678f930901f..e8e790feab4 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -19,7 +19,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .const import ( + CONF_TYPE_OWSERVER, + DEVICE_KEYS_0_7, + DEVICE_KEYS_A_B, + DOMAIN, + READ_MODE_BOOL, +) from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub @@ -38,129 +44,45 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { read_mode=READ_MODE_BOOL, ), ), - "12": ( - OneWireSwitchEntityDescription( - key="PIO.A", - entity_registry_enabled_default=False, - name="PIO A", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.B", - entity_registry_enabled_default=False, - name="PIO B", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.A", - entity_registry_enabled_default=False, - name="Latch A", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.B", - entity_registry_enabled_default=False, - name="Latch B", - read_mode=READ_MODE_BOOL, - ), + "12": tuple( + [ + OneWireSwitchEntityDescription( + key=f"PIO.{id}", + entity_registry_enabled_default=False, + name=f"PIO {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_A_B + ] + + [ + OneWireSwitchEntityDescription( + key=f"latch.{id}", + entity_registry_enabled_default=False, + name=f"Latch {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_A_B + ] ), - "29": ( - OneWireSwitchEntityDescription( - key="PIO.0", - entity_registry_enabled_default=False, - name="PIO 0", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.1", - entity_registry_enabled_default=False, - name="PIO 1", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.2", - entity_registry_enabled_default=False, - name="PIO 2", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.3", - entity_registry_enabled_default=False, - name="PIO 3", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.4", - entity_registry_enabled_default=False, - name="PIO 4", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.5", - entity_registry_enabled_default=False, - name="PIO 5", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.6", - entity_registry_enabled_default=False, - name="PIO 6", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="PIO.7", - entity_registry_enabled_default=False, - name="PIO 7", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.0", - entity_registry_enabled_default=False, - name="Latch 0", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.1", - entity_registry_enabled_default=False, - name="Latch 1", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.2", - entity_registry_enabled_default=False, - name="Latch 2", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.3", - entity_registry_enabled_default=False, - name="Latch 3", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.4", - entity_registry_enabled_default=False, - name="Latch 4", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.5", - entity_registry_enabled_default=False, - name="Latch 5", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.6", - entity_registry_enabled_default=False, - name="Latch 6", - read_mode=READ_MODE_BOOL, - ), - OneWireSwitchEntityDescription( - key="latch.7", - entity_registry_enabled_default=False, - name="Latch 7", - read_mode=READ_MODE_BOOL, - ), + "29": tuple( + [ + OneWireSwitchEntityDescription( + key=f"PIO.{id}", + entity_registry_enabled_default=False, + name=f"PIO {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_0_7 + ] + + [ + OneWireSwitchEntityDescription( + key=f"latch.{id}", + entity_registry_enabled_default=False, + name=f"Latch {id}", + read_mode=READ_MODE_BOOL, + ) + for id in DEVICE_KEYS_0_7 + ] ), } diff --git a/homeassistant/components/onewire/translations/hu.json b/homeassistant/components/onewire/translations/hu.json index e2c7ffa8c03..4d53659788d 100644 --- a/homeassistant/components/onewire/translations/hu.json +++ b/homeassistant/components/onewire/translations/hu.json @@ -10,7 +10,7 @@ "step": { "owserver": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "Owserver adatok be\u00e1ll\u00edt\u00e1sa" diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index ef20c1054f3..614612ecc27 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -145,16 +145,22 @@ def determine_zones(receiver): out = {"zone2": False, "zone3": False} try: _LOGGER.debug("Checking for zone 2 capability") - receiver.raw("ZPWQSTN") - out["zone2"] = True + response = receiver.raw("ZPWQSTN") + if response != "ZPWN/A": # Zone 2 Available + out["zone2"] = True + else: + _LOGGER.debug("Zone 2 not available") except ValueError as error: if str(error) != TIMEOUT_MESSAGE: raise error _LOGGER.debug("Zone 2 timed out, assuming no functionality") try: _LOGGER.debug("Checking for zone 3 capability") - receiver.raw("PW3QSTN") - out["zone3"] = True + response = receiver.raw("PW3QSTN") + if response != "PW3N/A": + out["zone3"] = True + else: + _LOGGER.debug("Zone 3 not available") except ValueError as error: if str(error) != TIMEOUT_MESSAGE: raise error diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 9ebf87a4132..1d08ec04f46 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -373,10 +373,13 @@ class ONVIFDevice: ) return - req.Velocity = { - "PanTilt": {"x": pan_val, "y": tilt_val}, - "Zoom": {"x": zoom_val}, - } + velocity = {} + if pan is not None or tilt is not None: + velocity["PanTilt"] = {"x": pan_val, "y": tilt_val} + if zoom is not None: + velocity["Zoom"] = {"x": zoom_val} + + req.Velocity = velocity await ptz_service.ContinuousMove(req) await asyncio.sleep(continuous_duration) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index a45cc02c84b..f76efb2bc8e 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress import datetime as dt -from typing import Callable from httpx import RemoteProtocolError, TransportError from onvif import ONVIFCamera, ONVIFService diff --git a/homeassistant/components/onvif/translations/fr.json b/homeassistant/components/onvif/translations/fr.json index 76eb733db3d..4ea0ae566cc 100644 --- a/homeassistant/components/onvif/translations/fr.json +++ b/homeassistant/components/onvif/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Le p\u00e9riph\u00e9rique ONVIF est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration pour le p\u00e9riph\u00e9rique ONVIF est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "no_h264": "Aucun flux H264 n'\u00e9tait disponible. V\u00e9rifiez la configuration du profil sur votre appareil.", "no_mac": "Impossible de configurer l'ID unique pour le p\u00e9riph\u00e9rique ONVIF.", "onvif_error": "Erreur lors de la configuration du p\u00e9riph\u00e9rique ONVIF. Consultez les journaux pour plus d'informations." @@ -43,7 +43,7 @@ }, "manual_input": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom", "port": "Port" }, diff --git a/homeassistant/components/onvif/translations/hu.json b/homeassistant/components/onvif/translations/hu.json index c43df53ae9f..f0df008f145 100644 --- a/homeassistant/components/onvif/translations/hu.json +++ b/homeassistant/components/onvif/translations/hu.json @@ -2,10 +2,10 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", - "no_h264": "Nem voltak el\u00e9rhet\u0151 H264 streamek. Ellen\u0151rizd a profil konfigur\u00e1ci\u00f3j\u00e1t a k\u00e9sz\u00fcl\u00e9ken.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "no_h264": "Nem voltak el\u00e9rhet\u0151 H264 streamek. Ellen\u0151rizze a profil konfigur\u00e1ci\u00f3j\u00e1t a k\u00e9sz\u00fcl\u00e9ken.", "no_mac": "Nem siker\u00fclt konfigur\u00e1lni az egyedi azonos\u00edt\u00f3t az ONVIF eszk\u00f6zh\u00f6z.", - "onvif_error": "Hiba t\u00f6rt\u00e9nt az ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sakor. Tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt ellen\u0151rizd a napl\u00f3kat." + "onvif_error": "Hiba t\u00f6rt\u00e9nt az ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sakor. Tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt ellen\u0151rizze a napl\u00f3kat." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" @@ -20,7 +20,7 @@ }, "configure": { "data": { - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", @@ -37,13 +37,13 @@ }, "device": { "data": { - "host": "V\u00e1laszd ki a felfedezett ONVIF eszk\u00f6zt" + "host": "V\u00e1lassza ki a felfedezett ONVIF eszk\u00f6zt" }, "title": "ONVIF eszk\u00f6z kiv\u00e1laszt\u00e1sa" }, "manual_input": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "port": "Port" }, @@ -53,7 +53,7 @@ "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.", + "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.", "title": "ONVIF eszk\u00f6z be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/onvif/translations/id.json b/homeassistant/components/onvif/translations/id.json index 3ed50ae63c4..6fcb49dcd99 100644 --- a/homeassistant/components/onvif/translations/id.json +++ b/homeassistant/components/onvif/translations/id.json @@ -18,6 +18,15 @@ }, "title": "Konfigurasikan autentikasi" }, + "configure": { + "data": { + "host": "Host", + "name": "Nama", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + } + }, "configure_profile": { "data": { "include": "Buat entitas kamera" diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index b7b8024009b..047acf66bb8 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.21.1", "opencv-python-headless==4.5.2.54"], + "requirements": ["numpy==1.21.2", "opencv-python-headless==4.5.2.54"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index 2f4d2e09cfb..5ea3af79ae4 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -1 +1,38 @@ -"""The opengarage component.""" +"""The OpenGarage integration.""" +from __future__ import annotations + +import opengarage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DEVICE_KEY, DOMAIN + +PLATFORMS = ["cover"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OpenGarage from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + hass.data[DOMAIN][entry.entry_id] = opengarage.OpenGarage( + f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", + entry.data[CONF_DEVICE_KEY], + entry.data[CONF_VERIFY_SSL], + async_get_clientsession(hass), + ) + + 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/opengarage/config_flow.py b/homeassistant/components/opengarage/config_flow.py new file mode 100644 index 00000000000..6ddc186cb9c --- /dev/null +++ b/homeassistant/components/opengarage/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for OpenGarage integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import opengarage +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +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 homeassistant.helpers.device_registry import format_mac + +from .const import CONF_DEVICE_KEY, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_KEY): str, + vol.Required(CONF_HOST, default="http://"): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + + +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 STEP_USER_DATA_SCHEMA with values provided by the user. + """ + open_garage = opengarage.OpenGarage( + f"{data[CONF_HOST]}:{data[CONF_PORT]}", + data[CONF_DEVICE_KEY], + data[CONF_VERIFY_SSL], + async_get_clientsession(hass), + ) + + try: + status = await open_garage.update_state() + except aiohttp.ClientError as exp: + raise CannotConnect from exp + + if status is None: + raise InvalidAuth + + return {"title": status.get("name"), "unique_id": format_mac(status["mac"])} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenGarage.""" + + VERSION = 1 + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + + user_input = { + CONF_DEVICE_KEY: import_info[CONF_DEVICE_KEY], + CONF_HOST: f"{'https' if import_info.get(CONF_SSL, False) else 'http'}://{import_info[CONF_HOST]}", + CONF_PORT: import_info.get(CONF_PORT, DEFAULT_PORT), + CONF_VERIFY_SSL: import_info.get(CONF_VERIFY_SSL, False), + } + return await self.async_step_user(user_input) + + 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 = {} + + try: + info = 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: + await self.async_set_unique_id(info["unique_id"]) + self._abort_if_unique_id_configured() + + 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 + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/opengarage/const.py b/homeassistant/components/opengarage/const.py new file mode 100644 index 00000000000..7cf9287e182 --- /dev/null +++ b/homeassistant/components/opengarage/const.py @@ -0,0 +1,11 @@ +"""Constants for the OpenGarage integration.""" + +ATTR_DISTANCE_SENSOR = "distance_sensor" +ATTR_DOOR_STATE = "door_state" +ATTR_SIGNAL_STRENGTH = "wifi_signal" + +CONF_DEVICE_KEY = "device_key" + +DEFAULT_NAME = "OpenGarage" +DEFAULT_PORT = 80 +DOMAIN = "opengarage" diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 154cb4df3ae..12a1103f7df 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,9 +1,9 @@ """Platform for the opengarage.io cover component.""" import logging -import opengarage import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.cover import ( DEVICE_CLASS_GARAGE, PLATFORM_SCHEMA, @@ -23,21 +23,19 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac + +from .const import ( + ATTR_DISTANCE_SENSOR, + ATTR_DOOR_STATE, + ATTR_SIGNAL_STRENGTH, + CONF_DEVICE_KEY, + DEFAULT_PORT, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE_SENSOR = "distance_sensor" -ATTR_DOOR_STATE = "door_state" -ATTR_SIGNAL_STRENGTH = "wifi_signal" - -CONF_DEVICE_KEY = "device_key" - -DEFAULT_NAME = "OpenGarage" -DEFAULT_PORT = 80 - STATES_MAP = {0: STATE_CLOSED, 1: STATE_OPEN} COVER_SCHEMA = vol.Schema( @@ -58,53 +56,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the OpenGarage covers.""" - covers = [] + _LOGGER.warning( + "Open Garage YAML configuration is deprecated, " + "it has been imported into the UI automatically and can be safely removed" + ) devices = config.get(CONF_COVERS) - for device_config in devices.values(): - opengarage_url = ( - f"{'https' if device_config[CONF_SSL] else 'http'}://" - f"{device_config.get(CONF_HOST)}:{device_config.get(CONF_PORT)}" - ) - - open_garage = opengarage.OpenGarage( - opengarage_url, - device_config[CONF_DEVICE_KEY], - device_config[CONF_VERIFY_SSL], - async_get_clientsession(hass), - ) - status = await open_garage.update_state() - covers.append( - OpenGarageCover( - device_config.get(CONF_NAME), open_garage, format_mac(status["mac"]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=device_config, ) ) - async_add_entities(covers, True) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the OpenGarage covers.""" + async_add_entities( + [OpenGarageCover(hass.data[DOMAIN][entry.entry_id], entry.unique_id)], True + ) class OpenGarageCover(CoverEntity): """Representation of a OpenGarage cover.""" - def __init__(self, name, open_garage, device_id): + _attr_device_class = DEVICE_CLASS_GARAGE + _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + + def __init__(self, open_garage, device_id): """Initialize the cover.""" - self._name = name self._open_garage = open_garage self._state = None self._state_before_move = None self._extra_state_attributes = {} - self._available = True - self._device_id = device_id - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def available(self): - """Return True if entity is available.""" - return self._available + self._attr_unique_id = self._device_id = device_id @property def extra_state_attributes(self): @@ -116,7 +102,21 @@ class OpenGarageCover(CoverEntity): """Return if the cover is closed.""" if self._state is None: return None - return self._state in [STATE_CLOSED, STATE_OPENING] + return self._state == STATE_CLOSED + + @property + def is_closing(self): + """Return if the cover is closing.""" + if self._state is None: + return None + return self._state == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening.""" + if self._state is None: + return None + return self._state == STATE_OPENING async def async_close_cover(self, **kwargs): """Close the cover.""" @@ -139,11 +139,11 @@ class OpenGarageCover(CoverEntity): status = await self._open_garage.update_state() if status is None: _LOGGER.error("Unable to connect to OpenGarage device") - self._available = False + self._attr_available = False return - if self._name is None and status["name"] is not None: - self._name = status["name"] + if self.name is None and status["name"] is not None: + self._attr_name = status["name"] state = STATES_MAP.get(status.get("door")) if self._state_before_move is not None: if self._state_before_move != state: @@ -152,7 +152,7 @@ class OpenGarageCover(CoverEntity): else: self._state = state - _LOGGER.debug("%s status: %s", self._name, self._state) + _LOGGER.debug("%s status: %s", self.name, self._state) if status.get("rssi") is not None: self._extra_state_attributes[ATTR_SIGNAL_STRENGTH] = status.get("rssi") if status.get("dist") is not None: @@ -160,7 +160,7 @@ class OpenGarageCover(CoverEntity): if self._state is not None: self._extra_state_attributes[ATTR_DOOR_STATE] = self._state - self._available = True + self._attr_available = True async def _push_button(self): """Send commands to API.""" @@ -171,24 +171,19 @@ class OpenGarageCover(CoverEntity): return if result == 2: - _LOGGER.error("Unable to control %s: Device key is incorrect", self._name) + _LOGGER.error("Unable to control %s: Device key is incorrect", self.name) elif result > 2: - _LOGGER.error("Unable to control %s: Error code %s", self._name, result) + _LOGGER.error("Unable to control %s: Error code %s", self.name, result) self._state = self._state_before_move self._state_before_move = None @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_GARAGE - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def unique_id(self): - """Return a unique ID.""" - return self._device_id + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self.name, + "manufacturer": "Open Garage", + } + return device_info diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index b6c617408b5..bf32b060f11 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -2,7 +2,12 @@ "domain": "opengarage", "name": "OpenGarage", "documentation": "https://www.home-assistant.io/integrations/opengarage", - "codeowners": ["@danielhiversen"], - "requirements": ["open-garage==0.1.5"], - "iot_class": "local_polling" -} + "codeowners": [ + "@danielhiversen" + ], + "requirements": [ + "open-garage==0.1.5" + ], + "iot_class": "local_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json new file mode 100644 index 00000000000..20e90386b45 --- /dev/null +++ b/homeassistant/components/opengarage/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device_key": "Device key", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/ca.json b/homeassistant/components/opengarage/translations/ca.json new file mode 100644 index 00000000000..6a8e611b188 --- /dev/null +++ b/homeassistant/components/opengarage/translations/ca.json @@ -0,0 +1,22 @@ +{ + "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": { + "device_key": "Clau del dispositiu", + "host": "Amfitri\u00f3", + "port": "Port", + "verify_ssl": "Verifica el certificat SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/de.json b/homeassistant/components/opengarage/translations/de.json new file mode 100644 index 00000000000..4e39620a9a9 --- /dev/null +++ b/homeassistant/components/opengarage/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "device_key": "Ger\u00e4teschl\u00fcssel", + "host": "Host", + "port": "Port", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/en.json b/homeassistant/components/opengarage/translations/en.json new file mode 100644 index 00000000000..9a103e2a1c0 --- /dev/null +++ b/homeassistant/components/opengarage/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "device_key": "Device key", + "host": "Host", + "port": "Port", + "verify_ssl": "Verify SSL certificate" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/fi.json b/homeassistant/components/opengarage/translations/es.json similarity index 69% rename from homeassistant/components/tesla/translations/fi.json rename to homeassistant/components/opengarage/translations/es.json index b7ed0a4bd5c..77ca2a2d001 100644 --- a/homeassistant/components/tesla/translations/fi.json +++ b/homeassistant/components/opengarage/translations/es.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "mfa": "MFA-koodi (valinnainen)" + "device_key": "Clave del dispositivo" } } } diff --git a/homeassistant/components/opengarage/translations/et.json b/homeassistant/components/opengarage/translations/et.json new file mode 100644 index 00000000000..eb25c27492b --- /dev/null +++ b/homeassistant/components/opengarage/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamnie nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "device_key": "Seadme v\u00f5ti", + "host": "Host", + "port": "Port", + "verify_ssl": "Kontrolli SSL serti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/he.json b/homeassistant/components/opengarage/translations/he.json new file mode 100644 index 00000000000..7f9a4197d54 --- /dev/null +++ b/homeassistant/components/opengarage/translations/he.json @@ -0,0 +1,22 @@ +{ + "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": { + "device_key": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05ea\u05e7\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4", + "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/hu.json b/homeassistant/components/opengarage/translations/hu.json new file mode 100644 index 00000000000..2c7687261c8 --- /dev/null +++ b/homeassistant/components/opengarage/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "device_key": "Eszk\u00f6zkulcs", + "host": "C\u00edm", + "port": "Port", + "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/it.json b/homeassistant/components/opengarage/translations/it.json new file mode 100644 index 00000000000..0bd8adf23e0 --- /dev/null +++ b/homeassistant/components/opengarage/translations/it.json @@ -0,0 +1,22 @@ +{ + "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": { + "device_key": "Chiave del dispositivo", + "host": "Host", + "port": "Porta", + "verify_ssl": "Verificare il certificato SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/nl.json b/homeassistant/components/opengarage/translations/nl.json new file mode 100644 index 00000000000..96190a06817 --- /dev/null +++ b/homeassistant/components/opengarage/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "device_key": "Apparaatsleutel", + "host": "Host", + "port": "Poort", + "verify_ssl": "SSL-certificaat verifi\u00ebren" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/no.json b/homeassistant/components/opengarage/translations/no.json new file mode 100644 index 00000000000..5c5189a9de9 --- /dev/null +++ b/homeassistant/components/opengarage/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "device_key": "Enhetsn\u00f8kkel", + "host": "Vert", + "port": "Port", + "verify_ssl": "Verifisere SSL-sertifikat" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/ru.json b/homeassistant/components/opengarage/translations/ru.json new file mode 100644 index 00000000000..85f528778bf --- /dev/null +++ b/homeassistant/components/opengarage/translations/ru.json @@ -0,0 +1,22 @@ +{ + "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.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "device_key": "\u041a\u043b\u044e\u0447 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442", + "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/zh-Hans.json b/homeassistant/components/opengarage/translations/zh-Hans.json new file mode 100644 index 00000000000..4f99ec0f978 --- /dev/null +++ b/homeassistant/components/opengarage/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/zh-Hant.json b/homeassistant/components/opengarage/translations/zh-Hant.json new file mode 100644 index 00000000000..fffbd19b551 --- /dev/null +++ b/homeassistant/components/opengarage/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "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", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "device_key": "\u88dd\u7f6e\u5bc6\u9470", + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0", + "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/translations/fr.json b/homeassistant/components/opentherm_gw/translations/fr.json index 6b9bf032244..32b642fa639 100644 --- a/homeassistant/components/opentherm_gw/translations/fr.json +++ b/homeassistant/components/opentherm_gw/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "error": { - "already_configured": "Passerelle d\u00e9j\u00e0 configur\u00e9e", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", "id_exists": "L'identifiant de la passerelle existe d\u00e9j\u00e0" }, diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index bb04bda4cb4..7f091bc1a79 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,9 +1,13 @@ """Support for OpenUV sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_MINUTES, UV_INDEX +from homeassistant.const import DEVICE_CLASS_OZONE, TIME_MINUTES, UV_INDEX from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime @@ -46,14 +50,16 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, name="Current Ozone Level", - icon="mdi:vector-triangle", + device_class=DEVICE_CLASS_OZONE, native_unit_of_measurement="du", + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_INDEX, name="Current UV Index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_LEVEL, @@ -65,42 +71,49 @@ SENSOR_DESCRIPTIONS = ( name="Max UV Index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_1, name="Skin Type 1 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_2, name="Skin Type 2 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_3, name="Skin Type 3 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_4, name="Skin Type 4 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_5, name="Skin Type 5 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_6, name="Skin Type 6 Safe Exposure Time", icon="mdi:timer-outline", native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/openuv/translations/el.json b/homeassistant/components/openuv/translations/el.json new file mode 100644 index 00000000000..a22e4c27ec9 --- /dev/null +++ b/homeassistant/components/openuv/translations/el.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0391\u03c1\u03c7\u03b9\u03ba\u03cc\u03c2 \u03b4\u03b5\u03af\u03ba\u03c4\u03b7\u03c2 UV \u03b3\u03b9\u03b1 \u03c4\u03bf \u03c0\u03b1\u03c1\u03ac\u03b8\u03c5\u03c1\u03bf \u03c0\u03c1\u03bf\u03c3\u03c4\u03b1\u03c3\u03af\u03b1\u03c2", + "to_window": "\u03a4\u03b5\u03bb\u03b9\u03ba\u03cc\u03c2 \u03b4\u03b5\u03af\u03ba\u03c4\u03b7\u03c2 UV \u03b3\u03b9\u03b1 \u03c4\u03bf \u03c0\u03b1\u03c1\u03ac\u03b8\u03c5\u03c1\u03bf \u03c0\u03c1\u03bf\u03c3\u03c4\u03b1\u03c3\u03af\u03b1\u03c2" + }, + "title": "\u0394\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 {intergration}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/es.json b/homeassistant/components/openuv/translations/es.json index 7736adaa674..014eba04f52 100644 --- a/homeassistant/components/openuv/translations/es.json +++ b/homeassistant/components/openuv/translations/es.json @@ -17,5 +17,16 @@ "title": "Completa tu informaci\u00f3n" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u00cdndice UV inicial para la ventana de protecci\u00f3n", + "to_window": "\u00cdndice UV final para la ventana de protecci\u00f3n" + }, + "title": "Configurar OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/et.json b/homeassistant/components/openuv/translations/et.json index b238b0a0964..89bfcd38318 100644 --- a/homeassistant/components/openuv/translations/et.json +++ b/homeassistant/components/openuv/translations/et.json @@ -17,5 +17,16 @@ "title": "Sisesta oma teave" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Ohutu UV indeksi algv\u00e4\u00e4rtus", + "to_window": "Ohutu UV indeksi l\u00f5ppv\u00e4\u00e4rtus" + }, + "title": "OpenUV seadistamine" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/fr.json b/homeassistant/components/openuv/translations/fr.json index 60000cd0058..6acd3144a10 100644 --- a/homeassistant/components/openuv/translations/fr.json +++ b/homeassistant/components/openuv/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_api_key": "Cl\u00e9 d'API invalide" + "invalid_api_key": "Cl\u00e9 API invalide" }, "step": { "user": { @@ -17,5 +17,16 @@ "title": "Veuillez saisir vos informations" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Indice UV de d\u00e9part pour la fen\u00eatre de protection", + "to_window": "Indice UV de fin pour la fen\u00eatre de protection" + }, + "title": "Configurer OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/hu.json b/homeassistant/components/openuv/translations/hu.json index b5c0e5ec608..1c3c6387dd3 100644 --- a/homeassistant/components/openuv/translations/hu.json +++ b/homeassistant/components/openuv/translations/hu.json @@ -17,5 +17,16 @@ "title": "T\u00f6ltsd ki az adataid" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Kezd\u0151 UV index a v\u00e9d\u0151ablakhoz", + "to_window": "A v\u00e9d\u0151ablak UV-index\u00e9nek v\u00e9ge" + }, + "title": "Konfigur\u00e1lja az OpenUV-t" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/it.json b/homeassistant/components/openuv/translations/it.json index ab7cfd39af9..241e118a800 100644 --- a/homeassistant/components/openuv/translations/it.json +++ b/homeassistant/components/openuv/translations/it.json @@ -17,5 +17,16 @@ "title": "Inserisci i tuoi dati" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Indice UV iniziale per la finestra di protezione", + "to_window": "Indice UV finale per la finestra di protezione" + }, + "title": "Configura OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/nl.json b/homeassistant/components/openuv/translations/nl.json index d7287b99ddf..bd56a1fa4e0 100644 --- a/homeassistant/components/openuv/translations/nl.json +++ b/homeassistant/components/openuv/translations/nl.json @@ -17,5 +17,16 @@ "title": "Vul uw gegevens in" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Uv-index starten voor het beschermingsvenster", + "to_window": "Uv-index voor het beveiligingsvenster be\u00ebindigen" + }, + "title": "Configureer OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/no.json b/homeassistant/components/openuv/translations/no.json index 0c4356c6f79..f76787b4e4d 100644 --- a/homeassistant/components/openuv/translations/no.json +++ b/homeassistant/components/openuv/translations/no.json @@ -17,5 +17,16 @@ "title": "Fyll ut informasjonen din" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Starter UV -indeks for beskyttelsesvinduet", + "to_window": "Avsluttende UV -indeks for beskyttelsesvinduet" + }, + "title": "Konfigurer OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/pl.json b/homeassistant/components/openuv/translations/pl.json index bd633e92f05..6aff15beef1 100644 --- a/homeassistant/components/openuv/translations/pl.json +++ b/homeassistant/components/openuv/translations/pl.json @@ -17,5 +17,16 @@ "title": "Wprowad\u017a dane" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Pocz\u0105tkowy indeks UV", + "to_window": "Ko\u0144cowy indeks UV" + }, + "title": "Konfiguracja OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/ru.json b/homeassistant/components/openuv/translations/ru.json index 405b9625d32..e4a2fe9f49c 100644 --- a/homeassistant/components/openuv/translations/ru.json +++ b/homeassistant/components/openuv/translations/ru.json @@ -17,5 +17,16 @@ "title": "OpenUV" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0423\u0424-\u0438\u043d\u0434\u0435\u043a\u0441\u0430, \u043d\u0430\u0447\u0438\u043d\u0430\u044f \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u043a\u043e\u043d", + "to_window": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0423\u0424-\u0438\u043d\u0434\u0435\u043a\u0441\u0430, \u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u044f \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u043a\u043e\u043d" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/te.json b/homeassistant/components/openuv/translations/te.json new file mode 100644 index 00000000000..56a9eae6ee6 --- /dev/null +++ b/homeassistant/components/openuv/translations/te.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0c30\u0c15\u0c4d\u0c37\u0c23 \u0c35\u0c3f\u0c02\u0c21\u0c4b \u0c15\u0c4b\u0c38\u0c02 UV \u0c38\u0c42\u0c1a\u0c3f\u0c15\u0c28\u0c41 \u0c2a\u0c4d\u0c30\u0c3e\u0c30\u0c02\u0c2d\u0c3f\u0c38\u0c4d\u0c24\u0c4b\u0c02\u0c26\u0c3f", + "to_window": "\u0c30\u0c15\u0c4d\u0c37\u0c23 \u0c35\u0c3f\u0c02\u0c21\u0c4b \u0c15\u0c4a\u0c30\u0c15\u0c41 \u0c2f\u0c41\u0c35\u0c3f \u0c07\u0c02\u0c21\u0c46\u0c15\u0c4d\u0c38\u0c4d \u0c28\u0c3f \u0c2e\u0c41\u0c17\u0c3f\u0c38\u0c4d\u0c24\u0c41\u0c02\u0c26\u0c3f" + }, + "title": "OpenUV \u0c28\u0c3f \u0c15\u0c3e\u0c28\u0c4d\u0c2b\u0c3f\u0c17\u0c30\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/th.json b/homeassistant/components/openuv/translations/th.json new file mode 100644 index 00000000000..4f741655c5c --- /dev/null +++ b/homeassistant/components/openuv/translations/th.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "init": { + "data": { + "from_window": "\u0e40\u0e23\u0e34\u0e48\u0e21\u0e14\u0e31\u0e0a\u0e19\u0e35 UV \u0e2a\u0e4d\u0e32\u0e2b\u0e23\u0e31\u0e1a\u0e2b\u0e19\u0e49\u0e32\u0e15\u0e48\u0e32\u0e07\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19", + "to_window": "\u0e2a\u0e34\u0e49\u0e19\u0e2a\u0e38\u0e14\u0e14\u0e31\u0e0a\u0e19\u0e35 UV \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a\u0e2b\u0e19\u0e49\u0e32\u0e15\u0e48\u0e32\u0e07\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19" + }, + "title": "\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32 OpenUV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/zh-Hans.json b/homeassistant/components/openuv/translations/zh-Hans.json index d23c6f3d52c..f9604321f10 100644 --- a/homeassistant/components/openuv/translations/zh-Hans.json +++ b/homeassistant/components/openuv/translations/zh-Hans.json @@ -14,5 +14,12 @@ "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f" } } + }, + "options": { + "step": { + "init": { + "title": "\u914d\u7f6e OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/zh-Hant.json b/homeassistant/components/openuv/translations/zh-Hant.json index f2b34ae8bf2..c8aeb8f4a55 100644 --- a/homeassistant/components/openuv/translations/zh-Hant.json +++ b/homeassistant/components/openuv/translations/zh-Hant.json @@ -17,5 +17,16 @@ "title": "\u586b\u5beb\u8cc7\u8a0a" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u958b\u59cb\u4fdd\u8b77\u7a97\u53e3\u4e4b\u7d2b\u5916\u7dda\u6307\u6578", + "to_window": "\u7d50\u675f\u4fdd\u8b77\u7a97\u53e3\u4e4b\u7d2b\u5916\u7dda\u6307\u6578" + }, + "title": "\u8a2d\u5b9a OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openweathermap/translations/fr.json b/homeassistant/components/openweathermap/translations/fr.json index e5b55db1ed3..f8879a04d32 100644 --- a/homeassistant/components/openweathermap/translations/fr.json +++ b/homeassistant/components/openweathermap/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration OpenWeatherMap pour ces coordonn\u00e9es est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "Cl\u00e9 d'API OpenWeatherMap", + "api_key": "Cl\u00e9 d'API", "language": "Langue", "latitude": "Latitude", "longitude": "Longitude", diff --git a/homeassistant/components/openweathermap/translations/hu.json b/homeassistant/components/openweathermap/translations/hu.json index 2fd2f0acc7a..99932ff5c68 100644 --- a/homeassistant/components/openweathermap/translations/hu.json +++ b/homeassistant/components/openweathermap/translations/hu.json @@ -17,7 +17,7 @@ "mode": "M\u00f3d", "name": "Az integr\u00e1ci\u00f3 neve" }, - "description": "Az OpenWeatherMap integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa. Az API kulcs l\u00e9trehoz\u00e1s\u00e1hoz menj az https://openweathermap.org/appid oldalra", + "description": "Az OpenWeatherMap integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa. Az API kulcs l\u00e9trehoz\u00e1s\u00e1hoz l\u00e1togasson el a https://openweathermap.org/appid oldalra", "title": "OpenWeatherMap" } } diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 73edc9fae75..5c2633a7a33 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -18,10 +18,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, ) -from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt +from homeassistant.util.temperature import kelvin_to_celsius from .const import ( ATTR_API_CLOUDS, @@ -83,9 +83,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _get_owm_weather(self): """Poll weather data from OWM.""" - if ( - self._forecast_mode == FORECAST_MODE_ONECALL_HOURLY - or self._forecast_mode == FORECAST_MODE_ONECALL_DAILY + if self._forecast_mode in ( + FORECAST_MODE_ONECALL_HOURLY, + FORECAST_MODE_ONECALL_DAILY, ): weather = await self.hass.async_add_executor_job( self._owm_client.one_call, self._latitude, self._longitude @@ -180,10 +180,10 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return forecast - def _fmt_dewpoint(self, dewpoint): + @staticmethod + def _fmt_dewpoint(dewpoint): if dewpoint is not None: - dewpoint = dewpoint - 273.15 - return round(self.hass.config.units.temperature(dewpoint, TEMP_CELSIUS), 1) + return round(kelvin_to_celsius(dewpoint), 1) return None @staticmethod diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index aa05c83ae76..f8c23b4f4f0 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -99,12 +99,10 @@ class OVOEnergyEntity(CoordinatorEntity): self, coordinator: DataUpdateCoordinator, client: OVOEnergy, - key: str, ) -> None: """Initialize the OVO Energy entity.""" super().__init__(coordinator) self._client = client - self._attr_unique_id = key class OVOEnergyDeviceEntity(OVOEnergyEntity): diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index cd84fa5a5d6..17f92a3b2e2 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,9 +1,10 @@ """Support for OVO Energy sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from typing import Callable, Final +from typing import Final from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy @@ -57,8 +58,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( name="OVO Last Electricity Cost", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_TOTAL_INCREASING, - icon="mdi:cash-multiple", - value=lambda usage: usage.electricity[-1].consumption, + value=lambda usage: usage.electricity[-1].cost.amount, ), OVOEnergySensorEntityDescription( key="last_electricity_start_time", @@ -92,7 +92,7 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_TOTAL_INCREASING, icon="mdi:cash-multiple", - value=lambda usage: usage.gas[-1].consumption, + value=lambda usage: usage.gas[-1].cost.amount, ), OVOEnergySensorEntityDescription( key="last_gas_start_time", @@ -157,8 +157,8 @@ class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): super().__init__( coordinator, client, - f"{DOMAIN}_{client.account_id}_{description.key}", ) + self._attr_unique_id = f"{DOMAIN}_{client.account_id}_{description.key}" self.entity_description = description @property diff --git a/homeassistant/components/ovo_energy/translations/ca.json b/homeassistant/components/ovo_energy/translations/ca.json index f8552caa86b..0d0677ec522 100644 --- a/homeassistant/components/ovo_energy/translations/ca.json +++ b/homeassistant/components/ovo_energy/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "error": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, diff --git a/homeassistant/components/ovo_energy/translations/id.json b/homeassistant/components/ovo_energy/translations/id.json index 05c38f244e7..fa072b59236 100644 --- a/homeassistant/components/ovo_energy/translations/id.json +++ b/homeassistant/components/ovo_energy/translations/id.json @@ -5,7 +5,7 @@ "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "OVO Energy: {username}", + "flow_title": "{username}", "step": { "reauth": { "data": { diff --git a/homeassistant/components/owntracks/translations/hu.json b/homeassistant/components/owntracks/translations/hu.json index f103fc9bbe1..84a40a1a593 100644 --- a/homeassistant/components/owntracks/translations/hu.json +++ b/homeassistant/components/owntracks/translations/hu.json @@ -4,11 +4,11 @@ "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "create_entry": { - "default": "\n\nAndroidon, nyisd meg [az OwnTracks appot]({android_url}), menj a preferences -> connectionre. V\u00e1ltoztasd meg a al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS-en, nyisd meg [az OwnTracks appot]({ios_url}), kattints az (i) ikonra bal oldalon fel\u00fcl -> settings. V\u00e1ltoztasd meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nN\u00e9zd meg [a dokument\u00e1ci\u00f3t]({docs_url}) tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt." + "default": "\n\nAndroidon, nyissa meg [az OwnTracks appot]({android_url}), majd v\u00e1lassza ki a Preferences -> Connection men\u00fct. V\u00e1ltoztassa meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS-en, nyissa meg [az OwnTracks appot]({ios_url}), kattintson az (i) ikonra bal oldalon fel\u00fcl -> settings. V\u00e1ltoztassa meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nN\u00e9zze meg [a dokument\u00e1ci\u00f3t]({docs_url}) tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt." }, "step": { "user": { - "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Owntracks-t?", + "description": "Biztos benne, hogy be szeretn\u00e9 \u00e1ll\u00edtani az Owntracks-t?", "title": "Owntracks be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index c3c23ea6741..238e7dcd8cd 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -370,7 +370,7 @@ async def async_handle_node_update(hass: HomeAssistant, node: OZWNode): return # update device in device registry with (updated) info for item in dev_registry.devices.values(): - if item.id != device.id and item.via_device_id != device.id: + if device.id not in (item.id, item.via_device_id): continue dev_name = create_device_name(node) dev_registry.async_update_device( diff --git a/homeassistant/components/ozw/config_flow.py b/homeassistant/components/ozw/config_flow.py index cc07d738488..c1dbbe2e093 100644 --- a/homeassistant/components/ozw/config_flow.py +++ b/homeassistant/components/ozw/config_flow.py @@ -35,15 +35,6 @@ class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.integration_created_addon = False self.install_task = None - async def async_step_import(self, data): - """Handle imported data. - - This step will be used when importing data during zwave to ozw migration. - """ - self.network_key = data.get(CONF_NETWORK_KEY) - self.usb_path = data.get(CONF_USB_PATH) - return await self.async_step_user() - async def async_step_user(self, user_input=None): """Handle the initial step.""" if self._async_current_entries(): diff --git a/homeassistant/components/ozw/migration.py b/homeassistant/components/ozw/migration.py deleted file mode 100644 index 86df69bc955..00000000000 --- a/homeassistant/components/ozw/migration.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Provide tools for migrating from the zwave integration.""" -from homeassistant.helpers.device_registry import ( - async_get_registry as async_get_device_registry, -) -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get_registry as async_get_entity_registry, -) - -from .const import DOMAIN, MIGRATED, NODES_VALUES -from .entity import create_device_id, create_value_id - -# The following dicts map labels between OpenZWave 1.4 and 1.6. -METER_CC_LABELS = { - "Energy": "Electric - kWh", - "Power": "Electric - W", - "Count": "Electric - Pulses", - "Voltage": "Electric - V", - "Current": "Electric - A", - "Power Factor": "Electric - PF", -} - -NOTIFICATION_CC_LABELS = { - "General": "Start", - "Smoke": "Smoke Alarm", - "Carbon Monoxide": "Carbon Monoxide", - "Carbon Dioxide": "Carbon Dioxide", - "Heat": "Heat", - "Flood": "Water", - "Access Control": "Access Control", - "Burglar": "Home Security", - "Power Management": "Power Management", - "System": "System", - "Emergency": "Emergency", - "Clock": "Clock", - "Appliance": "Appliance", - "HomeHealth": "Home Health", -} - -CC_ID_LABELS = { - 50: METER_CC_LABELS, - 113: NOTIFICATION_CC_LABELS, -} - - -async def async_get_migration_data(hass): - """Return dict with ozw side migration info.""" - data = {} - nodes_values = hass.data[DOMAIN][NODES_VALUES] - ozw_config_entries = hass.config_entries.async_entries(DOMAIN) - config_entry = ozw_config_entries[0] # ozw only has a single config entry - ent_reg = await async_get_entity_registry(hass) - entity_entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) - unique_entries = {entry.unique_id: entry for entry in entity_entries} - dev_reg = await async_get_device_registry(hass) - - for node_id, node_values in nodes_values.items(): - for entity_values in node_values: - unique_id = create_value_id(entity_values.primary) - if unique_id not in unique_entries: - continue - node = entity_values.primary.node - device_identifier = ( - DOMAIN, - create_device_id(node, entity_values.primary.instance), - ) - device_entry = dev_reg.async_get_device({device_identifier}, set()) - data[unique_id] = { - "node_id": node_id, - "node_instance": entity_values.primary.instance, - "device_id": device_entry.id, - "command_class": entity_values.primary.command_class.value, - "command_class_label": entity_values.primary.label, - "value_index": entity_values.primary.index, - "unique_id": unique_id, - "entity_entry": unique_entries[unique_id], - } - - return data - - -def map_node_values(zwave_data, ozw_data): - """Map zwave node values onto ozw node values.""" - migration_map = {"device_entries": {}, "entity_entries": {}} - - for zwave_entry in zwave_data.values(): - node_id = zwave_entry["node_id"] - node_instance = zwave_entry["node_instance"] - cc_id = zwave_entry["command_class"] - zwave_cc_label = zwave_entry["command_class_label"] - - if cc_id in CC_ID_LABELS: - labels = CC_ID_LABELS[cc_id] - ozw_cc_label = labels.get(zwave_cc_label, zwave_cc_label) - - ozw_entry = next( - ( - entry - for entry in ozw_data.values() - if entry["node_id"] == node_id - and entry["node_instance"] == node_instance - and entry["command_class"] == cc_id - and entry["command_class_label"] == ozw_cc_label - ), - None, - ) - else: - value_index = zwave_entry["value_index"] - - ozw_entry = next( - ( - entry - for entry in ozw_data.values() - if entry["node_id"] == node_id - and entry["node_instance"] == node_instance - and entry["command_class"] == cc_id - and entry["value_index"] == value_index - ), - None, - ) - - if ozw_entry is None: - continue - - # Save the zwave_entry under the ozw entity_id to create the map. - # Check that the mapped entities have the same domain. - if zwave_entry["entity_entry"].domain == ozw_entry["entity_entry"].domain: - migration_map["entity_entries"][ - ozw_entry["entity_entry"].entity_id - ] = zwave_entry - migration_map["device_entries"][ozw_entry["device_id"]] = zwave_entry[ - "device_id" - ] - - return migration_map - - -async def async_migrate(hass, migration_map): - """Perform zwave to ozw migration.""" - dev_reg = await async_get_device_registry(hass) - for ozw_device_id, zwave_device_id in migration_map["device_entries"].items(): - zwave_device_entry = dev_reg.async_get(zwave_device_id) - dev_reg.async_update_device( - ozw_device_id, - area_id=zwave_device_entry.area_id, - name_by_user=zwave_device_entry.name_by_user, - ) - - ent_reg = await async_get_entity_registry(hass) - for zwave_entry in migration_map["entity_entries"].values(): - zwave_entity_id = zwave_entry["entity_entry"].entity_id - ent_reg.async_remove(zwave_entity_id) - - for ozw_entity_id, zwave_entry in migration_map["entity_entries"].items(): - entity_entry = zwave_entry["entity_entry"] - ent_reg.async_update_entity( - ozw_entity_id, - new_entity_id=entity_entry.entity_id, - name=entity_entry.name, - icon=entity_entry.icon, - ) - - zwave_config_entry = hass.config_entries.async_entries("zwave")[0] - await hass.config_entries.async_remove(zwave_config_entry.entry_id) - - ozw_config_entry = hass.config_entries.async_entries("ozw")[0] - updates = { - **ozw_config_entry.data, - MIGRATED: True, - } - hass.config_entries.async_update_entry(ozw_config_entry, data=updates) diff --git a/homeassistant/components/ozw/translations/ca.json b/homeassistant/components/ozw/translations/ca.json index 835c16eb449..9c9fc17e58e 100644 --- a/homeassistant/components/ozw/translations/ca.json +++ b/homeassistant/components/ozw/translations/ca.json @@ -32,7 +32,7 @@ "start_addon": { "data": { "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" + "usb_path": "Ruta del dispositiu USB" }, "title": "Introdueix la configuraci\u00f3 del complement OpenZWave" } diff --git a/homeassistant/components/ozw/translations/fr.json b/homeassistant/components/ozw/translations/fr.json index 5e408b7b807..0a0232dd91d 100644 --- a/homeassistant/components/ozw/translations/fr.json +++ b/homeassistant/components/ozw/translations/fr.json @@ -4,8 +4,8 @@ "addon_info_failed": "Impossible d'obtenir les informations sur le module compl\u00e9mentaire OpenZWave.", "addon_install_failed": "\u00c9chec de l'installation du module compl\u00e9mentaire OpenZWave.", "addon_set_config_failed": "\u00c9chec de la configuration OpenZWave.", - "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours\u00e0", "mqtt_required": "L'int\u00e9gration MQTT n'est pas configur\u00e9e", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, diff --git a/homeassistant/components/ozw/translations/hu.json b/homeassistant/components/ozw/translations/hu.json index a43f234c909..06d921c86d3 100644 --- a/homeassistant/components/ozw/translations/hu.json +++ b/homeassistant/components/ozw/translations/hu.json @@ -5,7 +5,7 @@ "addon_install_failed": "Nem siker\u00fclt telep\u00edteni az OpenZWave b\u0151v\u00edtm\u00e9nyt.", "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani az OpenZWave konfigur\u00e1ci\u00f3t.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "mqtt_required": "Az MQTT integr\u00e1ci\u00f3 nincs be\u00e1ll\u00edtva", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, @@ -26,8 +26,8 @@ "data": { "use_addon": "Haszn\u00e1ld az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt" }, - "description": "Szeretn\u00e9d haszn\u00e1lni az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt?", - "title": "V\u00e1laszd ki a csatlakoz\u00e1si m\u00f3dot" + "description": "Szeretn\u00e9 haszn\u00e1lni az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt?", + "title": "V\u00e1lassza ki a csatlakoz\u00e1si m\u00f3dot" }, "start_addon": { "data": { diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py index 4b96c577bf2..bb55a686db8 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -25,7 +25,6 @@ from homeassistant.helpers import config_validation as cv from .const import ATTR_CONFIG_PARAMETER, ATTR_CONFIG_VALUE, DOMAIN, MANAGER from .lock import ATTR_USERCODE -from .migration import async_get_migration_data, async_migrate, map_node_values _LOGGER = logging.getLogger(__name__) @@ -58,7 +57,6 @@ ATTR_NEIGHBORS = "neighbors" @callback def async_register_api(hass): """Register all of our api endpoints.""" - websocket_api.async_register_command(hass, websocket_migrate_zwave) websocket_api.async_register_command(hass, websocket_get_instances) websocket_api.async_register_command(hass, websocket_get_nodes) websocket_api.async_register_command(hass, websocket_network_status) @@ -168,63 +166,6 @@ def _get_config_params(node, *args): return config_params -@websocket_api.require_admin -@websocket_api.async_response -@websocket_api.websocket_command( - { - vol.Required(TYPE): "ozw/migrate_zwave", - vol.Optional(DRY_RUN, default=True): bool, - } -) -async def websocket_migrate_zwave(hass, connection, msg): - """Migrate the zwave integration device and entity data to ozw integration.""" - if "zwave" not in hass.config.components: - _LOGGER.error("Can not migrate, zwave integration is not loaded") - connection.send_message( - websocket_api.error_message( - msg["id"], "zwave_not_loaded", "Integration zwave is not loaded" - ) - ) - return - - zwave = hass.components.zwave - zwave_data = await zwave.async_get_ozw_migration_data(hass) - _LOGGER.debug("Migration zwave data: %s", zwave_data) - - ozw_data = await async_get_migration_data(hass) - _LOGGER.debug("Migration ozw data: %s", ozw_data) - - can_migrate = map_node_values(zwave_data, ozw_data) - - zwave_entity_ids = [ - entry["entity_entry"].entity_id for entry in zwave_data.values() - ] - ozw_entity_ids = [entry["entity_entry"].entity_id for entry in ozw_data.values()] - migration_device_map = { - zwave_device_id: ozw_device_id - for ozw_device_id, zwave_device_id in can_migrate["device_entries"].items() - } - migration_entity_map = { - zwave_entry["entity_entry"].entity_id: ozw_entity_id - for ozw_entity_id, zwave_entry in can_migrate["entity_entries"].items() - } - _LOGGER.debug("Migration entity map: %s", migration_entity_map) - - if not msg[DRY_RUN]: - await async_migrate(hass, can_migrate) - - connection.send_result( - msg[ID], - { - "migration_device_map": migration_device_map, - "zwave_entity_ids": zwave_entity_ids, - "ozw_entity_ids": ozw_entity_ids, - "migration_entity_map": migration_entity_map, - "migrated": not msg[DRY_RUN], - }, - ) - - @websocket_api.websocket_command({vol.Required(TYPE): "ozw/get_instances"}) def websocket_get_instances(hass, connection, msg): """Get a list of OZW instances.""" diff --git a/homeassistant/components/p1_monitor/translations/cs.json b/homeassistant/components/p1_monitor/translations/cs.json new file mode 100644 index 00000000000..7981cfc800e --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/cs.json @@ -0,0 +1,16 @@ +{ + "config": { + "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/p1_monitor/translations/el.json b/homeassistant/components/p1_monitor/translations/el.json new file mode 100644 index 00000000000..00e89f9735d --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/el.json @@ -0,0 +1,13 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "description": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03bf {intergration} \u03b3\u03b9\u03b1 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03bc\u03b5 \u03c4\u03bf Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/es.json b/homeassistant/components/p1_monitor/translations/es.json new file mode 100644 index 00000000000..5c8552d224b --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nombre" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/fr.json b/homeassistant/components/p1_monitor/translations/fr.json new file mode 100644 index 00000000000..34c0a37fb2e --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + }, + "description": "Configurez P1 Monitor pour l'int\u00e9grer \u00e0 Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/hu.json b/homeassistant/components/p1_monitor/translations/hu.json new file mode 100644 index 00000000000..f9025022c6d --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm", + "name": "N\u00e9v" + }, + "description": "\u00c1ll\u00edtsa be a P1 monitort az Otthoni asszisztenssel val\u00f3 integr\u00e1ci\u00f3hoz." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/id.json b/homeassistant/components/p1_monitor/translations/id.json new file mode 100644 index 00000000000..8c96f3ee6cb --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nama" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/it.json b/homeassistant/components/p1_monitor/translations/it.json new file mode 100644 index 00000000000..25cac38aff6 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome" + }, + "description": "Configura P1 Monitor per l'integrazione con Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/fr.json b/homeassistant/components/panasonic_viera/translations/fr.json index 18add07074b..9684029e1d0 100644 --- a/homeassistant/components/panasonic_viera/translations/fr.json +++ b/homeassistant/components/panasonic_viera/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur Panasonic Viera est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "unknown": "Une erreur inconnue est survenue. Veuillez consulter les journaux pour obtenir plus de d\u00e9tails." + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -12,7 +12,7 @@ "step": { "pairing": { "data": { - "pin": "PIN" + "pin": "Code PIN" }, "description": "Entrer le code PIN affich\u00e9 sur votre t\u00e9l\u00e9viseur", "title": "Appairage" diff --git a/homeassistant/components/panasonic_viera/translations/hu.json b/homeassistant/components/panasonic_viera/translations/hu.json index df520bb1ca5..e373a352a45 100644 --- a/homeassistant/components/panasonic_viera/translations/hu.json +++ b/homeassistant/components/panasonic_viera/translations/hu.json @@ -14,7 +14,7 @@ "data": { "pin": "PIN-k\u00f3d" }, - "description": "Add meg a TV-k\u00e9sz\u00fcl\u00e9ken megjelen\u0151 PIN-k\u00f3dot", + "description": "Adja meg a TV-k\u00e9sz\u00fcl\u00e9ken megjelen\u0151 PIN-k\u00f3dot", "title": "P\u00e1ros\u00edt\u00e1s" }, "user": { @@ -22,7 +22,7 @@ "host": "IP c\u00edm", "name": "N\u00e9v" }, - "description": "Add meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet", + "description": "Adja meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet", "title": "A TV be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/person/translations/he.json b/homeassistant/components/person/translations/he.json index 1c36d16f936..1064c3f12b0 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 \u05d1\u05d1\u05d9\u05ea" + "not_home": "\u05d1\u05d7\u05d5\u05e5" } }, "title": "\u05d0\u05d3\u05dd" diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 1006df699f4..79698ea4136 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Callable +from typing import Any from haphilipsjs import ConnectionFailure, PhilipsTV diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py index 85b1a012860..09784dae63f 100644 --- a/homeassistant/components/philips_js/device_trigger.py +++ b/homeassistant/components/philips_js/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -46,10 +49,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE | None: """Attach a trigger.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] registry: DeviceRegistry = await async_get_registry(hass) if config[CONF_TYPE] == TRIGGER_TYPE_TURN_ON: variables = { diff --git a/homeassistant/components/philips_js/translations/hu.json b/homeassistant/components/philips_js/translations/hu.json index 1fe4811d21e..544b44ee8ee 100644 --- a/homeassistant/components/philips_js/translations/hu.json +++ b/homeassistant/components/philips_js/translations/hu.json @@ -20,7 +20,7 @@ "user": { "data": { "api_version": "API Verzi\u00f3", - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/philips_js/translations/pl.json b/homeassistant/components/philips_js/translations/pl.json index fce4ac34c83..7c1ef4b1b9e 100644 --- a/homeassistant/components/philips_js/translations/pl.json +++ b/homeassistant/components/philips_js/translations/pl.json @@ -27,7 +27,7 @@ }, "device_automation": { "trigger_type": { - "turn_on": "Urz\u0105dzenie zostanie poproszone o w\u0142\u0105czenie" + "turn_on": "urz\u0105dzenie zostanie poproszone o w\u0142\u0105czenie" } }, "options": { diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index f1ec1c6efd6..37167cb873a 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -22,8 +22,6 @@ DEFAULT_STATISTICS_ONLY = True SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" -ATTR_BLOCKED_DOMAINS = "domains_blocked" - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) DATA_KEY_API = "api" diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 0e231868647..656bd8a652b 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -14,7 +14,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity from .const import ( - ATTR_BLOCKED_DOMAINS, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, @@ -69,8 +68,3 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): return round(self.api.data[self.entity_description.key], 2) except TypeError: return self.api.data[self.entity_description.key] - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the Pi-hole.""" - return {ATTR_BLOCKED_DOMAINS: self.api.data["domains_being_blocked"]} diff --git a/homeassistant/components/pi_hole/translations/fr.json b/homeassistant/components/pi_hole/translations/fr.json index 152fb0f3def..4eb97a04756 100644 --- a/homeassistant/components/pi_hole/translations/fr.json +++ b/homeassistant/components/pi_hole/translations/fr.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_configured": "Service d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Connexion impossible" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "api_key": { "data": { - "api_key": "Clef d'API" + "api_key": "Cl\u00e9 d'API" } }, "user": { @@ -19,7 +19,7 @@ "location": "Emplacement", "name": "Nom", "port": "Port", - "ssl": "Utiliser SSL", + "ssl": "Utilise un certificat SSL", "statistics_only": "Statistiques uniquement", "verify_ssl": "V\u00e9rifier le certificat SSL" } diff --git a/homeassistant/components/pi_hole/translations/hu.json b/homeassistant/components/pi_hole/translations/hu.json index a8f8563da41..71321c4cf85 100644 --- a/homeassistant/components/pi_hole/translations/hu.json +++ b/homeassistant/components/pi_hole/translations/hu.json @@ -15,7 +15,7 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Hoszt", + "host": "C\u00edm", "location": "Elhelyezked\u00e9s", "name": "N\u00e9v", "port": "Port", diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 18a62589732..5aa21fd671b 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,5 +1,13 @@ """Constants for the Picnic integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import CURRENCY_EURO, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.typing import StateType DOMAIN = "picnic" @@ -28,91 +36,122 @@ SENSOR_LAST_ORDER_ETA_END = "last_order_eta_end" SENSOR_LAST_ORDER_DELIVERY_TIME = "last_order_delivery_time" SENSOR_LAST_ORDER_TOTAL_PRICE = "last_order_total_price" -SENSOR_TYPES = { - SENSOR_CART_ITEMS_COUNT: { - "icon": "mdi:format-list-numbered", - "data_type": CART_DATA, - "state": lambda cart: cart.get("total_count", 0), - }, - SENSOR_CART_TOTAL_PRICE: { - "unit": CURRENCY_EURO, - "icon": "mdi:currency-eur", - "default_enabled": True, - "data_type": CART_DATA, - "state": lambda cart: cart.get("total_price", 0) / 100, - }, - SENSOR_SELECTED_SLOT_START: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-start", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot.get("window_start"), - }, - SENSOR_SELECTED_SLOT_END: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-end", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot.get("window_end"), - }, - SENSOR_SELECTED_SLOT_MAX_ORDER_TIME: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:clock-alert-outline", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot.get("cut_off_time"), - }, - SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE: { - "unit": CURRENCY_EURO, - "icon": "mdi:currency-eur", - "default_enabled": True, - "data_type": SLOT_DATA, - "state": lambda slot: slot["minimum_order_value"] / 100 - if slot.get("minimum_order_value") - else None, - }, - SENSOR_LAST_ORDER_SLOT_START: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-start", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("slot", {}).get("window_start"), - }, - SENSOR_LAST_ORDER_SLOT_END: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:calendar-end", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("slot", {}).get("window_end"), - }, - SENSOR_LAST_ORDER_STATUS: { - "icon": "mdi:list-status", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("status"), - }, - SENSOR_LAST_ORDER_ETA_START: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:clock-start", - "default_enabled": True, - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("eta", {}).get("start"), - }, - SENSOR_LAST_ORDER_ETA_END: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:clock-end", - "default_enabled": True, - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("eta", {}).get("end"), - }, - SENSOR_LAST_ORDER_DELIVERY_TIME: { - "class": DEVICE_CLASS_TIMESTAMP, - "icon": "mdi:timeline-clock", - "default_enabled": True, - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("delivery_time", {}).get("start"), - }, - SENSOR_LAST_ORDER_TOTAL_PRICE: { - "unit": CURRENCY_EURO, - "icon": "mdi:cash-marker", - "data_type": LAST_ORDER_DATA, - "state": lambda last_order: last_order.get("total_price", 0) / 100, - }, -} + +@dataclass +class PicnicRequiredKeysMixin: + """Mixin for required keys.""" + + data_type: Literal["cart_data", "slot_data", "last_order_data"] + state: Callable[[Any], StateType] + + +@dataclass +class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): + """Describes Picnic sensor entity.""" + + entity_registry_enabled_default: bool = False + + +SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( + PicnicSensorEntityDescription( + key=SENSOR_CART_ITEMS_COUNT, + icon="mdi:format-list-numbered", + data_type="cart_data", + state=lambda cart: cart.get("total_count", 0), + ), + PicnicSensorEntityDescription( + key=SENSOR_CART_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="cart_data", + state=lambda cart: cart.get("total_price", 0) / 100, + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_START, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-start", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: slot.get("window_start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_END, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-end", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: slot.get("window_end"), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:clock-alert-outline", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: slot.get("cut_off_time"), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="slot_data", + state=lambda slot: ( + slot["minimum_order_value"] / 100 + if slot.get("minimum_order_value") + else None + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_START, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-start", + data_type="last_order_data", + state=lambda last_order: last_order.get("slot", {}).get("window_start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_END, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:calendar-end", + data_type="last_order_data", + state=lambda last_order: last_order.get("slot", {}).get("window_end"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_STATUS, + icon="mdi:list-status", + data_type="last_order_data", + state=lambda last_order: last_order.get("status"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_ETA_START, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:clock-start", + entity_registry_enabled_default=True, + data_type="last_order_data", + state=lambda last_order: last_order.get("eta", {}).get("start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_ETA_END, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:clock-end", + entity_registry_enabled_default=True, + data_type="last_order_data", + state=lambda last_order: last_order.get("eta", {}).get("end"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_DELIVERY_TIME, + device_class=DEVICE_CLASS_TIMESTAMP, + icon="mdi:timeline-clock", + entity_registry_enabled_default=True, + data_type="last_order_data", + state=lambda last_order: last_order.get("delivery_time", {}).get("start"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:cash-marker", + data_type="last_order_data", + state=lambda last_order: last_order.get("total_price", 0) / 100, + ), +) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 57f24180c03..34ad2943d8e 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -13,7 +13,14 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, SENSOR_TYPES +from .const import ( + ADDRESS, + ATTRIBUTION, + CONF_COORDINATOR, + DOMAIN, + SENSOR_TYPES, + PicnicSensorEntityDescription, +) async def async_setup_entry( @@ -24,8 +31,8 @@ async def async_setup_entry( # Add an entity for each sensor type async_add_entities( - PicnicSensor(picnic_coordinator, config_entry, sensor_type, props) - for sensor_type, props in SENSOR_TYPES.items() + PicnicSensor(picnic_coordinator, config_entry, description) + for description in SENSOR_TYPES ) return True @@ -34,71 +41,40 @@ async def async_setup_entry( class PicnicSensor(SensorEntity, CoordinatorEntity): """The CoordinatorEntity subclass representing Picnic sensors.""" + entity_description: PicnicSensorEntityDescription + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__( self, coordinator: DataUpdateCoordinator[Any], config_entry: ConfigEntry, - sensor_type, - properties, - ): + description: PicnicSensorEntityDescription, + ) -> None: """Init a Picnic sensor.""" super().__init__(coordinator) + self.entity_description = description - self.sensor_type = sensor_type - self.properties = properties - self.entity_id = f"sensor.picnic_{sensor_type}" + self.entity_id = f"sensor.picnic_{description.key}" self._service_unique_id = config_entry.unique_id - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return self.properties.get("unit") - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return f"{self._service_unique_id}.{self.sensor_type}" - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return self._to_capitalized_name(self.sensor_type) + self._attr_name = self._to_capitalized_name(description.key) + self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" @property def native_value(self) -> StateType: """Return the state of the entity.""" data_set = ( - self.coordinator.data.get(self.properties["data_type"], {}) + self.coordinator.data.get(self.entity_description.data_type, {}) if self.coordinator.data is not None else {} ) - return self.properties["state"](data_set) - - @property - def device_class(self) -> str | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return self.properties.get("class") - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - return self.properties["icon"] + return self.entity_description.state(data_set) @property def available(self) -> bool: """Return True if entity is available.""" return self.coordinator.last_update_success and 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.properties.get("default_enabled", False) - - @property - def extra_state_attributes(self): - """Return the sensor specific state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property def device_info(self): """Return device info.""" diff --git a/homeassistant/components/picnic/translations/fr.json b/homeassistant/components/picnic/translations/fr.json index 75e35a951de..03b5566566f 100644 --- a/homeassistant/components/picnic/translations/fr.json +++ b/homeassistant/components/picnic/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/picnic/translations/id.json b/homeassistant/components/picnic/translations/id.json index 0455a5b3b5e..819125c6909 100644 --- a/homeassistant/components/picnic/translations/id.json +++ b/homeassistant/components/picnic/translations/id.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { diff --git a/homeassistant/components/picnic/translations/ko.json b/homeassistant/components/picnic/translations/ko.json new file mode 100644 index 00000000000..fe58774c459 --- /dev/null +++ b/homeassistant/components/picnic/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \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", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "country_code": "\uad6d\uac00 \ucf54\ub4dc", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index c214061c416..333ee6b6e95 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -29,7 +29,6 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID, - HTTP_OK, TEMP_CELSIUS, TEMP_FAHRENHEIT, VOLUME_GALLONS, @@ -199,7 +198,7 @@ async def handle_webhook(hass, webhook_id, request): async_dispatcher_send(hass, SENSOR_UPDATE, *(device_id, sensor_data)) - return web.Response(text=f"Saving status for {device_id}", status=HTTP_OK) + return web.Response(text=f"Saving status for {device_id}") def _device_id(data): diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index 1700b803775..2d8cf40c91e 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -26,7 +26,7 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" DEFAULT_SCAN_INTERVAL = 5 MIN_UPDATE_INTERVAL = timedelta(minutes=1) -DEVICE_STATE_ATTRIBUTES = { +EXTRA_STATE_ATTRIBUTES = { "beer_name": "beer_name", "keg_date": "keg_date", "mode": "mode", diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index a28dfefb567..3c04c5d597d 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -7,9 +7,9 @@ from .const import ( DEVICE, DEVICE_ID, DEVICE_NAME, - DEVICE_STATE_ATTRIBUTES, DEVICE_TYPE, DOMAIN, + EXTRA_STATE_ATTRIBUTES, SENSOR_DATA, SENSOR_SIGNAL, ) @@ -73,7 +73,7 @@ class PlaatoEntity(entity.Entity): if self._attributes: return { attr_key: self._attributes[plaato_key] - for attr_key, plaato_key in DEVICE_STATE_ATTRIBUTES.items() + for attr_key, plaato_key in EXTRA_STATE_ATTRIBUTES.items() if plaato_key in self._attributes and self._attributes[plaato_key] is not None } diff --git a/homeassistant/components/plaato/translations/ca.json b/homeassistant/components/plaato/translations/ca.json index c4669b219ab..06aa27e5b37 100644 --- a/homeassistant/components/plaato/translations/ca.json +++ b/homeassistant/components/plaato/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", "webhook_not_internet_accessible": "La teva inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per poder rebre missatges webhook." }, diff --git a/homeassistant/components/plaato/translations/es.json b/homeassistant/components/plaato/translations/es.json index e0b6c767043..d38bf2a8265 100644 --- a/homeassistant/components/plaato/translations/es.json +++ b/homeassistant/components/plaato/translations/es.json @@ -6,7 +6,7 @@ "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, "create_entry": { - "default": "Para enviar eventos a Home Assistant, necesitar\u00e1s configurar la funci\u00f3n de webhook en Plaato Airlock.\n\nCompleta la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST\n\nEcha un vistazo a [la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." + "default": "\u00a1Tu Plaato {device_type} con nombre **{device_name}** se configur\u00f3 correctamente!" }, "error": { "invalid_webhook_device": "Has seleccionado un dispositivo que no admite el env\u00edo de datos a un webhook. Solo est\u00e1 disponible para Airlock", diff --git a/homeassistant/components/plaato/translations/fr.json b/homeassistant/components/plaato/translations/fr.json index ab3c01144dd..3bac269eaf9 100644 --- a/homeassistant/components/plaato/translations/fr.json +++ b/homeassistant/components/plaato/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9ja configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "webhook_not_internet_accessible": "Votre installation de Home Assistant doit \u00eatre accessible depuis internet pour recevoir des messages webhook." }, @@ -27,7 +27,7 @@ "device_name": "Nommez votre appareil", "device_type": "Type d'appareil Plaato" }, - "description": "\u00cates-vous s\u00fbr de vouloir installer le Plaato Airlock ?", + "description": "Voulez-vous commencer la configuration ?", "title": "Configurer le Webhook Plaato" }, "webhook": { diff --git a/homeassistant/components/plaato/translations/hu.json b/homeassistant/components/plaato/translations/hu.json index 4778b41e8be..a25c0c35672 100644 --- a/homeassistant/components/plaato/translations/hu.json +++ b/homeassistant/components/plaato/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { "default": "A Plaato {device_type} **{device_name}** n\u00e9vvel sikeresen telep\u00edtve lett!" @@ -27,11 +27,11 @@ "device_name": "Eszk\u00f6z neve", "device_type": "A Plaato eszk\u00f6z t\u00edpusa" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 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} ).", + "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\u00e1ssa a [dokument\u00e1ci\u00f3t]({docs_url}).", "title": "Haszn\u00e1land\u00f3 webhook" } } diff --git a/homeassistant/components/plaato/translations/nl.json b/homeassistant/components/plaato/translations/nl.json index 23fae52b020..7dc3eaf6fb7 100644 --- a/homeassistant/components/plaato/translations/nl.json +++ b/homeassistant/components/plaato/translations/nl.json @@ -27,7 +27,7 @@ "device_name": "Geef uw apparaat een naam", "device_type": "Type Plaato-apparaat" }, - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Stel de Plaato-apparaten in" }, "webhook": { diff --git a/homeassistant/components/plant/translations/hu.json b/homeassistant/components/plant/translations/hu.json index 3206ef7064d..ad2061411f5 100644 --- a/homeassistant/components/plant/translations/hu.json +++ b/homeassistant/components/plant/translations/hu.json @@ -5,5 +5,5 @@ "problem": "Probl\u00e9ma" } }, - "title": "N\u00f6v\u00e9ny" + "title": "N\u00f6v\u00e9nyfigyel\u0151" } \ No newline at end of file diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index e18d72337ca..cffb484ac5a 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -422,6 +422,7 @@ class PlexAuthorizationCallbackView(HomeAssistantView): async def get(self, request): """Receive authorization confirmation.""" + # pylint: disable=no-self-use hass = request.app["hass"] await hass.config_entries.flow.async_configure( flow_id=request.query["flow_id"], user_input=None diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py new file mode 100644 index 00000000000..c534eca1f27 --- /dev/null +++ b/homeassistant/components/plex/helpers.py @@ -0,0 +1,27 @@ +"""Helper methods for common Plex integration operations.""" + + +def pretty_title(media, short_name=False): + """Return a formatted title for the given media item.""" + year = None + if media.type == "album": + if short_name: + title = media.title + else: + title = f"{media.parentTitle} - {media.title}" + elif media.type == "episode": + title = f"{media.seasonEpisode.upper()} - {media.title}" + if not short_name: + title = f"{media.grandparentTitle} - {title}" + elif media.type == "track": + title = f"{media.index}. {media.title}" + else: + title = media.title + + if media.type in ["album", "movie", "season"]: + year = media.year + + if year: + title += f" ({year!s})" + + return title diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index ac3c6e8f8f8..6be7462da39 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -1,4 +1,5 @@ """Support to interface with the Plex API.""" +from itertools import islice import logging from homeassistant.components.media_player import BrowseMedia @@ -17,6 +18,7 @@ from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.errors import BrowseError from .const import DOMAIN +from .helpers import pretty_title class UnknownMediaType(BrowseError): @@ -32,9 +34,10 @@ PLAYLISTS_BROWSE_PAYLOAD = { "can_play": False, "can_expand": True, } -SPECIAL_METHODS = { - "On Deck": "onDeck", - "Recently Added": "recentlyAdded", + +LIBRARY_PREFERRED_LIBTYPE = { + "show": "episode", + "artist": "album", } ITEM_TYPE_MEDIA_CLASS = { @@ -57,7 +60,7 @@ def browse_media( # noqa: C901 ): """Implement the websocket media browsing helper.""" - def item_payload(item): + def item_payload(item, short_name=False): """Create response payload for a single media item.""" try: media_class = ITEM_TYPE_MEDIA_CLASS[item.type] @@ -65,7 +68,7 @@ def browse_media( # noqa: C901 _LOGGER.debug("Unknown type received: %s", item.type) raise UnknownMediaType from err payload = { - "title": item.title, + "title": pretty_title(item, short_name), "media_class": media_class, "media_content_id": str(item.ratingKey), "media_content_type": item.type, @@ -129,7 +132,7 @@ def browse_media( # noqa: C901 media_info.children = [] for item in media: try: - media_info.children.append(item_payload(item)) + media_info.children.append(item_payload(item, short_name=True)) except UnknownMediaType: continue return media_info @@ -180,8 +183,22 @@ def browse_media( # noqa: C901 "children_media_class": children_media_class, } - method = SPECIAL_METHODS[special_folder] - items = getattr(library_or_section, method)() + if special_folder == "On Deck": + items = library_or_section.onDeck() + elif special_folder == "Recently Added": + if library_or_section.TYPE: + libtype = LIBRARY_PREFERRED_LIBTYPE.get( + library_or_section.TYPE, library_or_section.TYPE + ) + items = library_or_section.recentlyAdded(libtype=libtype) + else: + recent_iter = ( + x + for x in library_or_section.search(sort="addedAt:desc", limit=100) + if x.type in ["album", "episode", "movie"] + ) + items = list(islice(recent_iter, 30)) + for item in items: try: payload["children"].append(item_payload(item)) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 0969967e673..db2ce15d395 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -16,6 +16,7 @@ from .const import ( PLEX_UPDATE_SENSOR_SIGNAL, SERVERS, ) +from .helpers import pretty_title LIBRARY_ATTRIBUTE_TYPES = { "artist": ["artist", "album"], @@ -28,6 +29,11 @@ LIBRARY_PRIMARY_LIBTYPE = { "artist": "track", } +LIBRARY_RECENT_LIBTYPE = { + "show": "episode", + "artist": "album", +} + LIBRARY_ICON_LOOKUP = { "artist": "mdi:music", "movie": "mdi:movie", @@ -174,6 +180,17 @@ class PlexLibrarySectionSensor(SensorEntity): libtype=libtype, includeCollections=False ) + recent_libtype = LIBRARY_RECENT_LIBTYPE.get( + self.library_type, self.library_type + ) + recently_added = self.library_section.recentlyAdded( + maxresults=1, libtype=recent_libtype + ) + if recently_added: + media = recently_added[0] + self._attr_extra_state_attributes["last_added_item"] = pretty_title(media) + self._attr_extra_state_attributes["last_added_timestamp"] = media.addedAt + @property def device_info(self): """Return a device description for device registry.""" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index bfe6375f5ac..c16a84b1cd8 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -19,7 +19,7 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "token": "Token (Optional)" + "token": "Token (optional)" } }, "select_server": { diff --git a/homeassistant/components/plex/translations/en.json b/homeassistant/components/plex/translations/en.json index 87615c9f42e..834594e1d27 100644 --- a/homeassistant/components/plex/translations/en.json +++ b/homeassistant/components/plex/translations/en.json @@ -22,7 +22,7 @@ "host": "Host", "port": "Port", "ssl": "Uses an SSL certificate", - "token": "Token (Optional)", + "token": "Token (optional)", "verify_ssl": "Verify SSL certificate" }, "title": "Manual Plex Configuration" diff --git a/homeassistant/components/plex/translations/fr.json b/homeassistant/components/plex/translations/fr.json index 63a2413316e..73704d31c7a 100644 --- a/homeassistant/components/plex/translations/fr.json +++ b/homeassistant/components/plex/translations/fr.json @@ -3,10 +3,10 @@ "abort": { "all_configured": "Tous les serveurs li\u00e9s sont d\u00e9j\u00e0 configur\u00e9s", "already_configured": "Ce serveur Plex est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Plex en cours de configuration", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "token_request_timeout": "D\u00e9lai d'obtention du jeton", - "unknown": "\u00c9chec pour une raison inconnue" + "unknown": "Erreur inattendue" }, "error": { "faulty_credentials": "L'autorisation \u00e0 \u00e9chou\u00e9e", @@ -19,9 +19,9 @@ "step": { "manual_setup": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "port": "Port", - "ssl": "Utiliser SSL", + "ssl": "Utilise un certificat SSL", "token": "Jeton (facultatif)", "verify_ssl": "V\u00e9rifier le certificat SSL" }, diff --git a/homeassistant/components/plex/translations/hu.json b/homeassistant/components/plex/translations/hu.json index c0ecbe3e02c..cde11b9c7cc 100644 --- a/homeassistant/components/plex/translations/hu.json +++ b/homeassistant/components/plex/translations/hu.json @@ -3,15 +3,15 @@ "abort": { "all_configured": "Az \u00f6sszes \u00f6sszekapcsolt szerver m\u00e1r konfigur\u00e1lva van", "already_configured": "Ez a Plex szerver m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", "token_request_timeout": "Token k\u00e9r\u00e9sre sz\u00e1nt id\u0151 lej\u00e1rt", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { - "faulty_credentials": "A hiteles\u00edt\u00e9s sikertelen", - "host_or_token": "Legal\u00e1bb egyet kell megadnia a Gazdag\u00e9p vagy a Token k\u00f6z\u00fcl", - "no_servers": "Nincs szerver csatlakoztatva a fi\u00f3khoz", + "faulty_credentials": "A hiteles\u00edt\u00e9s sikertelen, k\u00e9rem, ellen\u0151rizze a token-t", + "host_or_token": "Legal\u00e1bb egyet kell megadnia a C\u00edm vagy a Token k\u00f6z\u00fcl", + "no_servers": "Nincsenek Plex-fi\u00f3khoz kapcsol\u00f3d\u00f3 kiszolg\u00e1l\u00f3k", "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3", "ssl_error": "SSL tan\u00fas\u00edtv\u00e1ny probl\u00e9ma" }, @@ -19,7 +19,7 @@ "step": { "manual_setup": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "token": "Token (opcion\u00e1lis)", diff --git a/homeassistant/components/plugwise/translations/fr.json b/homeassistant/components/plugwise/translations/fr.json index f89c8509136..ce262e72f24 100644 --- a/homeassistant/components/plugwise/translations/fr.json +++ b/homeassistant/components/plugwise/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Ce Smile est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification invalide, v\u00e9rifiez les 8 caract\u00e8res de votre ID Smile", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "flow_title": "Smile: {name}", diff --git a/homeassistant/components/plugwise/translations/id.json b/homeassistant/components/plugwise/translations/id.json index 9047bf477bd..f3eeb0926ba 100644 --- a/homeassistant/components/plugwise/translations/id.json +++ b/homeassistant/components/plugwise/translations/id.json @@ -8,7 +8,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Smile: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/plum_lightpad/translations/ca.json b/homeassistant/components/plum_lightpad/translations/ca.json index 86f649d57d7..c1854b868e6 100644 --- a/homeassistant/components/plum_lightpad/translations/ca.json +++ b/homeassistant/components/plum_lightpad/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/plum_lightpad/translations/fr.json b/homeassistant/components/plum_lightpad/translations/fr.json index cee1b083f7f..20c633e8d0f 100644 --- a/homeassistant/components/plum_lightpad/translations/fr.json +++ b/homeassistant/components/plum_lightpad/translations/fr.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Mot de passe", - "username": "Adresse e-mail" + "username": "Email" } } } diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index fffb1b07f25..13a1ac5ce23 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -3,7 +3,7 @@ "name": "Minut Point", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/point", - "requirements": ["pypoint==2.1.0"], + "requirements": ["pypoint==2.2.0"], "dependencies": ["webhook", "http"], "codeowners": ["@fredrike"], "quality_scale": "gold", diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 8d4ee69fca2..bb98ccb53d9 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -11,10 +11,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - PRESSURE_HPA, SOUND_PRESSURE_WEIGHTED_DBA, TEMP_CELSIUS, ) @@ -50,12 +48,6 @@ SENSOR_TYPES: tuple[MinutPointSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, ), - MinutPointSensorEntityDescription( - key="pressure", - precision=0, - device_class=DEVICE_CLASS_PRESSURE, - native_unit_of_measurement=PRESSURE_HPA, - ), MinutPointSensorEntityDescription( key="humidity", precision=1, diff --git a/homeassistant/components/point/translations/he.json b/homeassistant/components/point/translations/he.json index 24decb09dd8..a226a9e4c6d 100644 --- a/homeassistant/components/point/translations/he.json +++ b/homeassistant/components/point/translations/he.json @@ -3,7 +3,8 @@ "abort": { "already_setup": "\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.", "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.", - "no_flows": "\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_flows": "\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.", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." }, "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" diff --git a/homeassistant/components/point/translations/hu.json b/homeassistant/components/point/translations/hu.json index 17dc73a189b..c582bbfc7cd 100644 --- a/homeassistant/components/point/translations/hu.json +++ b/homeassistant/components/point/translations/hu.json @@ -4,26 +4,26 @@ "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.", + "no_flows": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "unknown_authorize_url_generation": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n." }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" }, "error": { - "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot", + "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", "no_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token" }, "step": { "auth": { - "description": "K\u00e9rlek k\u00f6vesd az al\u00e1bbi linket \u00e9s a **Fogadd el** a hozz\u00e1f\u00e9r\u00e9st a Minut fi\u00f3kj\u00e1hoz, majd t\u00e9rj vissza \u00e9s nyomd meg a **K\u00fcld\u00e9s ** gombot. \n\n [Link]({authorization_url})", + "description": "K\u00e9rem k\u00f6vesse az al\u00e1bbi linket \u00e9s a **Fogadja el** a hozz\u00e1f\u00e9r\u00e9st a Minut fi\u00f3kj\u00e1hoz, majd t\u00e9rjen vissza \u00e9s nyomja meg a **K\u00fcld\u00e9s ** gombot. \n\n [Link]({authorization_url})", "title": "Point hiteles\u00edt\u00e9se" }, "user": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" } } diff --git a/homeassistant/components/point/translations/nl.json b/homeassistant/components/point/translations/nl.json index 37dae8481eb..f0ab4d8696e 100644 --- a/homeassistant/components/point/translations/nl.json +++ b/homeassistant/components/point/translations/nl.json @@ -23,7 +23,7 @@ "data": { "flow_impl": "Leverancier" }, - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Kies een authenticatie methode" } } diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 5ec1cb475b5..134be1cefba 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -7,16 +7,17 @@ from poolsense import PoolSense from poolsense.exceptions import PoolSenseError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import ATTR_ATTRIBUTION, CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from .const import DOMAIN +from .const import ATTRIBUTION, DOMAIN PLATFORMS = ["sensor", "binary_sensor"] @@ -61,16 +62,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class PoolSenseEntity(CoordinatorEntity): """Implements a common class elements representing the PoolSense component.""" - def __init__(self, coordinator, email, info_type): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__(self, coordinator, email, description: EntityDescription): """Initialize poolsense sensor.""" super().__init__(coordinator) - self._unique_id = f"{email}-{info_type}" - self.info_type = info_type - - @property - def unique_id(self): - """Return a unique id.""" - return self._unique_id + self.entity_description = description + self._attr_name = f"PoolSense {description.name}" + self._attr_unique_id = f"{email}-{description.key}" class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index ea07f1637a6..1b45ee15f0f 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -1,42 +1,40 @@ """Support for PoolSense binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PROBLEM, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import CONF_EMAIL from . import PoolSenseEntity from .const import DOMAIN -BINARY_SENSORS = { - "pH Status": { - "unit": None, - "icon": None, - "name": "pH Status", - "device_class": DEVICE_CLASS_PROBLEM, - }, - "Chlorine Status": { - "unit": None, - "icon": None, - "name": "Chlorine Status", - "device_class": DEVICE_CLASS_PROBLEM, - }, -} +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="pH Status", + name="pH Status", + device_class=DEVICE_CLASS_PROBLEM, + ), + BinarySensorEntityDescription( + key="Chlorine Status", + name="Chlorine Status", + device_class=DEVICE_CLASS_PROBLEM, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - binary_sensors_list = [] - for binary_sensor in BINARY_SENSORS: - binary_sensors_list.append( - PoolSenseBinarySensor( - coordinator, config_entry.data[CONF_EMAIL], binary_sensor - ) - ) + entities = [ + PoolSenseBinarySensor(coordinator, config_entry.data[CONF_EMAIL], description) + for description in BINARY_SENSOR_TYPES + ] - async_add_entities(binary_sensors_list, False) + async_add_entities(entities, False) class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): @@ -45,19 +43,4 @@ class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): @property def is_on(self): """Return true if the binary sensor is on.""" - return self.coordinator.data[self.info_type] == "red" - - @property - def icon(self): - """Return the icon.""" - return BINARY_SENSORS[self.info_type]["icon"] - - @property - def device_class(self): - """Return the class of this device.""" - return BINARY_SENSORS[self.info_type]["device_class"] - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"PoolSense {BINARY_SENSORS[self.info_type]['name']}" + return self.coordinator.data[self.entity_description.key] == "red" diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index e9aeaca20f5..82df8b4d208 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -1,7 +1,8 @@ """Sensor platform for the PoolSense sensor.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_EMAIL, DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, @@ -12,103 +13,80 @@ from homeassistant.const import ( ) from . import PoolSenseEntity -from .const import ATTRIBUTION, DOMAIN +from .const import DOMAIN -SENSORS = { - "Chlorine": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine", - "device_class": None, - }, - "pH": {"unit": None, "icon": "mdi:pool", "name": "pH", "device_class": None}, - "Battery": { - "unit": PERCENTAGE, - "icon": None, - "name": "Battery", - "device_class": DEVICE_CLASS_BATTERY, - }, - "Water Temp": { - "unit": TEMP_CELSIUS, - "icon": "mdi:coolant-temperature", - "name": "Temperature", - "device_class": DEVICE_CLASS_TEMPERATURE, - }, - "Last Seen": { - "unit": None, - "icon": "mdi:clock", - "name": "Last Seen", - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "Chlorine High": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine High", - "device_class": None, - }, - "Chlorine Low": { - "unit": ELECTRIC_POTENTIAL_MILLIVOLT, - "icon": "mdi:pool", - "name": "Chlorine Low", - "device_class": None, - }, - "pH High": { - "unit": None, - "icon": "mdi:pool", - "name": "pH High", - "device_class": None, - }, - "pH Low": { - "unit": None, - "icon": "mdi:pool", - "name": "pH Low", - "device_class": None, - }, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Chlorine", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + name="Chlorine", + ), + SensorEntityDescription( + key="pH", + icon="mdi:pool", + name="pH", + ), + SensorEntityDescription( + key="Battery", + native_unit_of_measurement=PERCENTAGE, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + ), + SensorEntityDescription( + key="Water Temp", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:coolant-temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="Last Seen", + icon="mdi:clock", + name="Last Seen", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + SensorEntityDescription( + key="Chlorine High", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + name="Chlorine High", + ), + SensorEntityDescription( + key="Chlorine Low", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + name="Chlorine Low", + ), + SensorEntityDescription( + key="pH High", + icon="mdi:pool", + name="pH High", + ), + SensorEntityDescription( + key="pH Low", + icon="mdi:pool", + name="pH Low", + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors_list = [] - for sensor in SENSORS: - sensors_list.append( - PoolSenseSensor(coordinator, config_entry.data[CONF_EMAIL], sensor) - ) + entities = [ + PoolSenseSensor(coordinator, config_entry.data[CONF_EMAIL], description) + for description in SENSOR_TYPES + ] - async_add_entities(sensors_list, False) + async_add_entities(entities, False) class PoolSenseSensor(PoolSenseEntity, SensorEntity): """Sensor representing poolsense data.""" - @property - def name(self): - """Return the name of the particular component.""" - return f"PoolSense {SENSORS[self.info_type]['name']}" - @property def native_value(self): """State of the sensor.""" - return self.coordinator.data[self.info_type] - - @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 native_unit_of_measurement(self): - """Return unit of measurement.""" - return SENSORS[self.info_type]["unit"] - - @property - def extra_state_attributes(self): - """Return device attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/poolsense/translations/fr.json b/homeassistant/components/poolsense/translations/fr.json index 0a9fac75005..bfe2ecf1bd2 100644 --- a/homeassistant/components/poolsense/translations/fr.json +++ b/homeassistant/components/poolsense/translations/fr.json @@ -4,12 +4,12 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_auth": "L'authentification ne'st pas valide" + "invalid_auth": "Authentification invalide" }, "step": { "user": { "data": { - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "description": "Voulez-vous commencer la configuration ?", diff --git a/homeassistant/components/poolsense/translations/hu.json b/homeassistant/components/poolsense/translations/hu.json index 80562b34e28..39274e14c21 100644 --- a/homeassistant/components/poolsense/translations/hu.json +++ b/homeassistant/components/poolsense/translations/hu.json @@ -12,7 +12,7 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "PoolSense" } } diff --git a/homeassistant/components/poolsense/translations/nl.json b/homeassistant/components/poolsense/translations/nl.json index f88d14e297a..1fd59ebf2ea 100644 --- a/homeassistant/components/poolsense/translations/nl.json +++ b/homeassistant/components/poolsense/translations/nl.json @@ -12,7 +12,7 @@ "email": "E-mail", "password": "Wachtwoord" }, - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "PoolSense" } } diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 1cacaa5fc42..1b097b93408 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -1,5 +1,5 @@ """Support for powerwall binary sensors.""" -from tesla_powerwall import GridStatus +from tesla_powerwall import GridStatus, MeterType from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, @@ -142,4 +142,8 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): def is_on(self): """Powerwall is charging.""" # is_sending_to returns true for values greater than 100 watts - return self.coordinator.data[POWERWALL_API_METERS].battery.is_sending_to() + return ( + self.coordinator.data[POWERWALL_API_METERS] + .get_meter(MeterType.BATTERY) + .is_sending_to() + ) diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 5cee6c1fd19..802d1fdf5e3 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.3.10"], + "requirements": ["tesla-powerwall==0.3.11"], "codeowners": ["@bdraco", "@jrester"], "dhcp": [ { diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 940dcad8647..8c45a142206 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -53,7 +53,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): powerwalls_serial_numbers = powerwall_data[POWERWALL_API_SERIAL_NUMBERS] entities = [] - for meter in MeterType: + # coordinator.data[POWERWALL_API_METERS].meters holds all meters that are available + for meter in coordinator.data[POWERWALL_API_METERS].meters: entities.append( PowerWallEnergySensor( meter, diff --git a/homeassistant/components/powerwall/translations/fr.json b/homeassistant/components/powerwall/translations/fr.json index 3bfd70cd44c..61e69d3dedc 100644 --- a/homeassistant/components/powerwall/translations/fr.json +++ b/homeassistant/components/powerwall/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Le Powerwall est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue", "wrong_version": "Votre Powerwall utilise une version logicielle qui n'est pas prise en charge. Veuillez envisager de mettre \u00e0 niveau ou de signaler ce probl\u00e8me afin qu'il puisse \u00eatre r\u00e9solu." diff --git a/homeassistant/components/powerwall/translations/id.json b/homeassistant/components/powerwall/translations/id.json index a5ae5f5e979..95f8d600901 100644 --- a/homeassistant/components/powerwall/translations/id.json +++ b/homeassistant/components/powerwall/translations/id.json @@ -10,7 +10,7 @@ "unknown": "Kesalahan yang tidak diharapkan", "wrong_version": "Powerwall Anda menggunakan versi perangkat lunak yang tidak didukung. Pertimbangkan untuk memutakhirkan atau melaporkan masalah ini agar dapat diatasi." }, - "flow_title": "Tesla Powerwall ({ip_address})", + "flow_title": "{ip_address}", "step": { "user": { "data": { diff --git a/homeassistant/components/profiler/translations/hu.json b/homeassistant/components/profiler/translations/hu.json index c5d28903888..215cd02307b 100644 --- a/homeassistant/components/profiler/translations/hu.json +++ b/homeassistant/components/profiler/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/profiler/translations/nl.json b/homeassistant/components/profiler/translations/nl.json index 8690611b1c9..8b99a128bd3 100644 --- a/homeassistant/components/profiler/translations/nl.json +++ b/homeassistant/components/profiler/translations/nl.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/progettihwsw/translations/he.json b/homeassistant/components/progettihwsw/translations/he.json index 67c80866be0..4095264c76c 100644 --- a/homeassistant/components/progettihwsw/translations/he.json +++ b/homeassistant/components/progettihwsw/translations/he.json @@ -10,20 +10,20 @@ "step": { "relay_modes": { "data": { - "relay_1": "Relay 1", - "relay_10": "Relay 10", - "relay_11": "Relay 11", - "relay_12": "Relay 12", - "relay_13": "Relay 13", - "relay_15": "Relay 15", - "relay_2": "Relay 2", - "relay_3": "Relay 3", - "relay_4": "Relay 4", - "relay_5": "Relay 5", - "relay_6": "Relay 6", - "relay_7": "Relay 7", - "relay_8": "Relay 8", - "relay_9": "Relay 9" + "relay_1": "\u05de\u05de\u05e1\u05e8 1", + "relay_10": "\u05de\u05de\u05e1\u05e8 10", + "relay_11": "\u05de\u05de\u05e1\u05e8 11", + "relay_12": "\u05de\u05de\u05e1\u05e8 12", + "relay_13": "\u05de\u05de\u05e1\u05e8 13", + "relay_15": "\u05de\u05de\u05e1\u05e8 15", + "relay_2": "\u05de\u05de\u05e1\u05e8 2", + "relay_3": "\u05de\u05de\u05e1\u05e8 3", + "relay_4": "\u05de\u05de\u05e1\u05e8 4", + "relay_5": "\u05de\u05de\u05e1\u05e8 5", + "relay_6": "\u05de\u05de\u05e1\u05e8 6", + "relay_7": "\u05de\u05de\u05e1\u05e8 7", + "relay_8": "\u05de\u05de\u05e1\u05e8 8", + "relay_9": "\u05de\u05de\u05e1\u05e8 9" }, "title": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05de\u05de\u05e1\u05e8\u05d9\u05dd" }, diff --git a/homeassistant/components/progettihwsw/translations/hu.json b/homeassistant/components/progettihwsw/translations/hu.json index fea70ec88ac..84258a6a01b 100644 --- a/homeassistant/components/progettihwsw/translations/hu.json +++ b/homeassistant/components/progettihwsw/translations/hu.json @@ -31,7 +31,7 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "\u00c1ll\u00edtsa be" diff --git a/homeassistant/components/prosegur/translations/el.json b/homeassistant/components/prosegur/translations/el.json new file mode 100644 index 00000000000..c5dee661aa2 --- /dev/null +++ b/homeassistant/components/prosegur/translations/el.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "\u0395\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03bc\u03b5 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc Prosegur." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/es.json b/homeassistant/components/prosegur/translations/es.json index fbccb2f6391..af4f61d6fdc 100644 --- a/homeassistant/components/prosegur/translations/es.json +++ b/homeassistant/components/prosegur/translations/es.json @@ -1,27 +1,27 @@ { "config": { "abort": { - "already_configured": "El sistema ya est\u00e1 configurado", + "already_configured": "El dispositivo ya est\u00e1 configurado", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", - "unknown": "Error desconocido" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { "data": { "description": "Vuelva a autenticarse con su cuenta Prosegur.", - "password": "Clave", - "username": "Nombre de Usuario" + "password": "Contrase\u00f1a", + "username": "Usuario" } }, "user": { "data": { "country": "Pa\u00eds", - "password": "Clave", - "username": "Nombre de Usuario" + "password": "Contrase\u00f1a", + "username": "Usuario" } } } diff --git a/homeassistant/components/prosegur/translations/fr.json b/homeassistant/components/prosegur/translations/fr.json index 7c0d361da6a..42061673128 100644 --- a/homeassistant/components/prosegur/translations/fr.json +++ b/homeassistant/components/prosegur/translations/fr.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/prosegur/translations/id.json b/homeassistant/components/prosegur/translations/id.json new file mode 100644 index 00000000000..9616471c03a --- /dev/null +++ b/homeassistant/components/prosegur/translations/id.json @@ -0,0 +1,27 @@ +{ + "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" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, + "user": { + "data": { + "country": "Negara", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/fr.json b/homeassistant/components/ps4/translations/fr.json index 82fe682e8f6..c4765c31c4a 100644 --- a/homeassistant/components/ps4/translations/fr.json +++ b/homeassistant/components/ps4/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "credential_error": "Erreur lors de l'extraction des informations d'identification.", - "no_devices_found": "Aucun appareil PlayStation 4 trouv\u00e9 sur le r\u00e9seau.", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "port_987_bind_error": "Impossible de se connecter au port 997. Reportez-vous \u00e0 la [documentation] (https://www.home-assistant.io/components/ps4/) pour plus d'informations.", "port_997_bind_error": "Impossible de se connecter au port 997. Reportez-vous \u00e0 la [documentation] (https://www.home-assistant.io/components/ps4/) pour plus d'informations." }, @@ -20,7 +20,7 @@ }, "link": { "data": { - "code": "PIN", + "code": "Code PIN", "ip_address": "Adresse IP", "name": "Nom", "region": "R\u00e9gion" diff --git a/homeassistant/components/ps4/translations/he.json b/homeassistant/components/ps4/translations/he.json index e9543da8206..421f869d8c5 100644 --- a/homeassistant/components/ps4/translations/he.json +++ b/homeassistant/components/ps4/translations/he.json @@ -19,6 +19,9 @@ "region": "\u05d0\u05d9\u05d6\u05d5\u05e8" }, "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4" + }, + "mode": { + "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4" } } } diff --git a/homeassistant/components/ps4/translations/hu.json b/homeassistant/components/ps4/translations/hu.json index 97614bcac57..753ea60b282 100644 --- a/homeassistant/components/ps4/translations/hu.json +++ b/homeassistant/components/ps4/translations/hu.json @@ -9,7 +9,7 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "credential_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s. Az \u00fajraind\u00edt\u00e1shoz nyomja meg a bek\u00fcld\u00e9s gombot.", + "credential_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s. Az \u00fajraind\u00edt\u00e1shoz nyomja meg a K\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" }, @@ -30,7 +30,7 @@ }, "mode": { "data": { - "ip_address": "IP c\u00edm (Hagyd \u00fcresen az Automatikus Felder\u00edt\u00e9s haszn\u00e1lat\u00e1hoz).", + "ip_address": "IP c\u00edm (Hagyja \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.", diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/es.json b/homeassistant/components/pvpc_hourly_pricing/translations/es.json index 59c6f6de174..4af0de8f594 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/es.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/es.json @@ -24,7 +24,7 @@ "power_p3": "Potencia contratada para el per\u00edodo valle P3 (kW)", "tariff": "Tarifa aplicable por zona geogr\u00e1fica" }, - "description": "Este sensor utiliza la API oficial para obtener el [precio horario de la electricidad (PVPC)](https://www.esios.ree.es/es/pvpc) en Espa\u00f1a.\nPara una explicaci\u00f3n m\u00e1s precisa visite los [documentos de integraci\u00f3n](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "description": "Este sensor utiliza la API oficial para obtener el [precio horario de la electricidad (PVPC)](https://www.esios.ree.es/es/pvpc) en Espa\u00f1a.\nPara una explicaci\u00f3n m\u00e1s precisa visita los [documentos de integraci\u00f3n](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "Configuraci\u00f3n del sensor" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json index f8511a80579..e22a70092c3 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'int\u00e9gration est d\u00e9j\u00e0 configur\u00e9e avec un capteur existant avec ce tarif" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json index 1f706862ee1..c654c92969a 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json @@ -24,7 +24,7 @@ "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/).", + "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.\nPontosabb 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/id.json b/homeassistant/components/pvpc_hourly_pricing/translations/id.json index 8601c31fda0..9a8a18a7543 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/id.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/id.json @@ -7,10 +7,10 @@ "user": { "data": { "name": "Nama Sensor", - "tariff": "Tarif kontrak (1, 2, atau 3 periode)" + "tariff": "Tarif yang berlaku menurut zona geografis" }, - "description": "Sensor ini menggunakan API resmi untuk mendapatkan [harga listrik per jam (PVPC)](https://www.esios.ree.es/es/pvpc) di Spanyol.\nUntuk penjelasan yang lebih tepat, kunjungi [dokumen integrasi](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nPilih tarif kontrak berdasarkan jumlah periode penagihan per hari:\n- 1 periode: normal\n- 2 periode: diskriminasi (tarif per malam)\n- 3 periode: mobil listrik (tarif per malam 3 periode)", - "title": "Pemilihan tarif" + "description": "Sensor ini menggunakan API resmi untuk mendapatkan [harga listrik per jam (PVPC)](https://www.esios.ree.es/es/pvpc) di Spanyol.\nUntuk penjelasan yang lebih tepat, kunjungi [dokumen integrasi](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "Penyiapan sensor" } } } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 4663b203248..6dd52af5631 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_URL, CONF_USERNAME, - DATA_RATE_KILOBYTES_PER_SECOND, + DATA_RATE_KIBIBYTES_PER_SECOND, STATE_IDLE, ) from homeassistant.exceptions import PlatformNotReady @@ -39,12 +39,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", - native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, ), SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", - native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, ), ) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index b02c977d98d..91df03947a0 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,11 +1,17 @@ """Support for QNAP NAS Sensors.""" +from __future__ import annotations + from datetime import timedelta import logging from qnapstats import QNAPStats 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_NAME, CONF_HOST, @@ -56,57 +62,117 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) NOTIFICATION_ID = "qnap_notification" NOTIFICATION_TITLE = "QNAP Sensor Setup" -_SYSTEM_MON_COND = { - "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, None, DEVICE_CLASS_TEMPERATURE], - "cpu_usage": ["CPU Usage", PERCENTAGE, "mdi:chip", None], -} -_MEMORY_MON_COND = { - "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", - 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", - None, - ], - "drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], -} -_VOLUME_MON_COND = { - "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 = ( - list(_SYSTEM_MON_COND) - + list(_CPU_MON_COND) - + list(_MEMORY_MON_COND) - + list(_NETWORK_MON_COND) - + list(_DRIVE_MON_COND) - + list(_VOLUME_MON_COND) +_SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SensorEntityDescription( + key="system_temp", + name="System Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), ) +_CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="cpu_temp", + name="CPU Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="cpu_usage", + name="CPU Usage", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + ), +) +_MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="memory_free", + name="Memory Available", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + SensorEntityDescription( + key="memory_used", + name="Memory Used", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + SensorEntityDescription( + key="memory_percent_used", + name="Memory Usage", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), +) +_NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="network_link_status", + name="Network Link", + icon="mdi:checkbox-marked-circle-outline", + ), + SensorEntityDescription( + key="network_tx", + name="Network Up", + native_unit_of_measurement=DATA_RATE_MEBIBYTES_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="network_rx", + name="Network Down", + native_unit_of_measurement=DATA_RATE_MEBIBYTES_PER_SECOND, + icon="mdi:download", + ), +) +_DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="drive_smart_status", + name="SMART Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SensorEntityDescription( + key="drive_temp", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), +) +_VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="volume_size_used", + name="Used Space", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:chart-pie", + ), + SensorEntityDescription( + key="volume_size_free", + name="Free Space", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:chart-pie", + ), + SensorEntityDescription( + key="volume_percentage_used", + name="Volume Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chart-pie", + ), +) + +SENSOR_KEYS: list[str] = [ + desc.key + for desc in ( + *_SYSTEM_MON_COND, + *_CPU_MON_COND, + *_MEMORY_MON_COND, + *_NETWORK_MON_COND, + *_DRIVE_MON_COND, + *_VOLUME_MON_COND, + ) +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -118,7 +184,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NICS): cv.ensure_list, vol.Optional(CONF_DRIVES): cv.ensure_list, @@ -136,40 +202,61 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if not api.data: raise PlatformNotReady + monitored_conditions = config[CONF_MONITORED_CONDITIONS] sensors = [] # Basic sensors - for variable in config[CONF_MONITORED_CONDITIONS]: - if variable in _SYSTEM_MON_COND: - sensors.append(QNAPSystemSensor(api, variable, _SYSTEM_MON_COND[variable])) - if variable in _CPU_MON_COND: - sensors.append(QNAPCPUSensor(api, variable, _CPU_MON_COND[variable])) - if variable in _MEMORY_MON_COND: - sensors.append(QNAPMemorySensor(api, variable, _MEMORY_MON_COND[variable])) + sensors.extend( + [ + QNAPSystemSensor(api, description) + for description in _SYSTEM_MON_COND + if description.key in monitored_conditions + ] + ) + sensors.extend( + [ + QNAPCPUSensor(api, description) + for description in _CPU_MON_COND + if description.key in monitored_conditions + ] + ) + sensors.extend( + [ + QNAPMemorySensor(api, description) + for description in _MEMORY_MON_COND + if description.key in monitored_conditions + ] + ) # Network sensors - for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]): - sensors += [ - QNAPNetworkSensor(api, variable, _NETWORK_MON_COND[variable], nic) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _NETWORK_MON_COND + sensors.extend( + [ + QNAPNetworkSensor(api, description, nic) + for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]) + for description in _NETWORK_MON_COND + if description.key in monitored_conditions ] + ) # Drive sensors - for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]): - sensors += [ - QNAPDriveSensor(api, variable, _DRIVE_MON_COND[variable], drive) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _DRIVE_MON_COND + sensors.extend( + [ + QNAPDriveSensor(api, description, drive) + for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]) + for description in _DRIVE_MON_COND + if description.key in monitored_conditions ] + ) # Volume sensors - for volume in config.get(CONF_VOLUMES, api.data["volumes"]): - sensors += [ - QNAPVolumeSensor(api, variable, _VOLUME_MON_COND[variable], volume) - for variable in config[CONF_MONITORED_CONDITIONS] - if variable in _VOLUME_MON_COND + sensors.extend( + [ + QNAPVolumeSensor(api, description, volume) + for volume in config.get(CONF_VOLUMES, api.data["volumes"]) + for description in _VOLUME_MON_COND + if description.key in monitored_conditions ] + ) add_entities(sensors) @@ -218,15 +305,11 @@ class QNAPStatsAPI: class QNAPSensor(SensorEntity): """Base class for a QNAP sensor.""" - def __init__(self, api, variable, variable_info, monitor_device=None): + def __init__(self, api, description: SensorEntityDescription, monitor_device=None): """Initialize the sensor.""" - self.var_id = variable - self.var_name = variable_info[0] - self.var_units = variable_info[1] - self.var_icon = variable_info[2] + self.entity_description = description self.monitor_device = monitor_device self._api = api - self._attr_device_class = variable_info[3] @property def name(self): @@ -234,18 +317,10 @@ class QNAPSensor(SensorEntity): server_name = self._api.data["system_stats"]["system"]["name"] if self.monitor_device is not None: - return f"{server_name} {self.var_name} ({self.monitor_device})" - return f"{server_name} {self.var_name}" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self.var_icon - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.var_units + return ( + f"{server_name} {self.entity_description.name} ({self.monitor_device})" + ) + return f"{server_name} {self.entity_description.name}" def update(self): """Get the latest data for the states.""" @@ -258,9 +333,9 @@ class QNAPCPUSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - if self.var_id == "cpu_temp": + if self.entity_description.key == "cpu_temp": return self._api.data["system_stats"]["cpu"]["temp_c"] - if self.var_id == "cpu_usage": + if self.entity_description.key == "cpu_usage": return self._api.data["system_stats"]["cpu"]["usage_percent"] @@ -271,16 +346,16 @@ class QNAPMemorySensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" free = float(self._api.data["system_stats"]["memory"]["free"]) / 1024 - if self.var_id == "memory_free": + if self.entity_description.key == "memory_free": return round_nicely(free) total = float(self._api.data["system_stats"]["memory"]["total"]) / 1024 used = total - free - if self.var_id == "memory_used": + if self.entity_description.key == "memory_used": return round_nicely(used) - if self.var_id == "memory_percent_used": + if self.entity_description.key == "memory_percent_used": return round(used / total * 100) @property @@ -298,15 +373,15 @@ class QNAPNetworkSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - if self.var_id == "network_link_status": + if self.entity_description.key == "network_link_status": nic = self._api.data["system_stats"]["nics"][self.monitor_device] return nic["link_status"] data = self._api.data["bandwidth"][self.monitor_device] - if self.var_id == "network_tx": + if self.entity_description.key == "network_tx": return round_nicely(data["tx"] / 1024 / 1024) - if self.var_id == "network_rx": + if self.entity_description.key == "network_rx": return round_nicely(data["rx"] / 1024 / 1024) @property @@ -331,10 +406,10 @@ class QNAPSystemSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - if self.var_id == "status": + if self.entity_description.key == "status": return self._api.data["system_health"] - if self.var_id == "system_temp": + if self.entity_description.key == "system_temp": return int(self._api.data["system_stats"]["system"]["temp_c"]) @property @@ -362,10 +437,10 @@ class QNAPDriveSensor(QNAPSensor): """Return the state of the sensor.""" data = self._api.data["smart_drive_health"][self.monitor_device] - if self.var_id == "drive_smart_status": + if self.entity_description.key == "drive_smart_status": return data["health"] - if self.var_id == "drive_temp": + if self.entity_description.key == "drive_temp": return int(data["temp_c"]) if data["temp_c"] is not None else 0 @property @@ -373,7 +448,7 @@ class QNAPDriveSensor(QNAPSensor): """Return the name of the sensor, if any.""" server_name = self._api.data["system_stats"]["system"]["name"] - return f"{server_name} {self.var_name} (Drive {self.monitor_device})" + return f"{server_name} {self.entity_description.name} (Drive {self.monitor_device})" @property def extra_state_attributes(self): @@ -397,16 +472,16 @@ class QNAPVolumeSensor(QNAPSensor): data = self._api.data["volumes"][self.monitor_device] free_gb = int(data["free_size"]) / 1024 / 1024 / 1024 - if self.var_id == "volume_size_free": + if self.entity_description.key == "volume_size_free": return round_nicely(free_gb) total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 used_gb = total_gb - free_gb - if self.var_id == "volume_size_used": + if self.entity_description.key == "volume_size_used": return round_nicely(used_gb) - if self.var_id == "volume_percentage_used": + if self.entity_description.key == "volume_percentage_used": return round(used_gb / total_gb * 100) @property diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 6669a353094..8ac4c92582f 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -70,9 +70,6 @@ class RachioPerson: can_pause = True break - if not can_pause: - return - all_devices = [rachio_iro.name for rachio_iro in self._controllers] def pause_water(service): @@ -97,6 +94,16 @@ class RachioPerson: if iro.name in devices: iro.stop_watering() + hass.services.async_register( + DOMAIN, + SERVICE_STOP_WATERING, + stop_water, + schema=STOP_SERVICE_SCHEMA, + ) + + if not can_pause: + return + hass.services.async_register( DOMAIN, SERVICE_PAUSE_WATERING, @@ -111,13 +118,6 @@ class RachioPerson: schema=RESUME_SERVICE_SCHEMA, ) - hass.services.async_register( - DOMAIN, - SERVICE_STOP_WATERING, - stop_water, - schema=STOP_SERVICE_SCHEMA, - ) - def _setup(self, hass): """Rachio device setup.""" rachio = self.rachio @@ -134,7 +134,7 @@ class RachioPerson: for controller in devices: webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1] # The API does not provide a way to tell if a controller is shared - # or if they are the owner. To work around this problem we fetch the webooks + # or if they are the owner. To work around this problem we fetch the webhooks # before we setup the device so we can skip it instead of failing. # webhooks are normally a list, however if there is an error # rachio hands us back a dict diff --git a/homeassistant/components/rachio/translations/fr.json b/homeassistant/components/rachio/translations/fr.json index a52c0bd6d4a..343256cea9a 100644 --- a/homeassistant/components/rachio/translations/fr.json +++ b/homeassistant/components/rachio/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index fc108af56a7..112e9f3a76d 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -371,7 +371,7 @@ class RadioThermostat(ClimateEntity): def set_preset_mode(self, preset_mode): """Set Preset mode (Home, Alternate, Away, Holiday).""" - if preset_mode in (PRESET_MODES): + if preset_mode in PRESET_MODES: self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] else: _LOGGER.error( diff --git a/homeassistant/components/rainforest_eagle/translations/cs.json b/homeassistant/components/rainforest_eagle/translations/cs.json new file mode 100644 index 00000000000..aae081e61fe --- /dev/null +++ b/homeassistant/components/rainforest_eagle/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": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/el.json b/homeassistant/components/rainforest_eagle/translations/el.json new file mode 100644 index 00000000000..686a0d72c44 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/el.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7" + }, + "error": { + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "data": { + "cloud_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03bd\u03ad\u03c6\u03bf\u03c5\u03c2", + "install_code": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/es.json b/homeassistant/components/rainforest_eagle/translations/es.json new file mode 100644 index 00000000000..08649fda7ec --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/es.json @@ -0,0 +1,19 @@ +{ + "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": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/fr.json b/homeassistant/components/rainforest_eagle/translations/fr.json new file mode 100644 index 00000000000..9631ff6cc93 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/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", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "cloud_id": "Nom d'utilisateur cloud", + "host": "H\u00f4te", + "install_code": "Code d'installation" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/hu.json b/homeassistant/components/rainforest_eagle/translations/hu.json new file mode 100644 index 00000000000..c3d489c8eec --- /dev/null +++ b/homeassistant/components/rainforest_eagle/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": { + "cloud_id": "Cloud ID", + "host": "C\u00edm", + "install_code": "Telep\u00edt\u00e9si k\u00f3d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/id.json b/homeassistant/components/rainforest_eagle/translations/id.json new file mode 100644 index 00000000000..80db8f3182d --- /dev/null +++ b/homeassistant/components/rainforest_eagle/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": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/it.json b/homeassistant/components/rainforest_eagle/translations/it.json new file mode 100644 index 00000000000..f01839b59da --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/it.json @@ -0,0 +1,21 @@ +{ + "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": { + "cloud_id": "ID Cloud", + "host": "Host", + "install_code": "Codice di installazione" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index f990dd5c672..57b51472a6f 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -4,7 +4,12 @@ from __future__ import annotations from dataclasses import dataclass from functools import partial -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, @@ -45,6 +50,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:water-pump", native_unit_of_measurement=f"clicks/{VOLUME_CUBIC_METERS}", entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, ), RainMachineSensorEntityDescription( @@ -53,6 +59,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:water-pump", native_unit_of_measurement="liter", entity_registry_enabled_default=False, + state_class=STATE_CLASS_TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, ), RainMachineSensorEntityDescription( @@ -69,6 +76,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:water-pump", native_unit_of_measurement="clicks", entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, ), RainMachineSensorEntityDescription( @@ -77,6 +85,7 @@ SENSOR_DESCRIPTIONS = ( icon="mdi:thermometer", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, api_category=DATA_RESTRICTIONS_UNIVERSAL, ), ) diff --git a/homeassistant/components/rainmachine/translations/fr.json b/homeassistant/components/rainmachine/translations/fr.json index df0f9efa588..de4e5cdc1ed 100644 --- a/homeassistant/components/rainmachine/translations/fr.json +++ b/homeassistant/components/rainmachine/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce contr\u00f4leur RainMachine est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_auth": "Authentification invalide" diff --git a/homeassistant/components/rainmachine/translations/hu.json b/homeassistant/components/rainmachine/translations/hu.json index 1ff7dc34b9c..c6120797a72 100644 --- a/homeassistant/components/rainmachine/translations/hu.json +++ b/homeassistant/components/rainmachine/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "ip_address": "Hosztn\u00e9v vagy IP c\u00edm", + "ip_address": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port" }, diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 5d6b66d8abd..d542199e096 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -33,12 +33,6 @@ 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[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[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 9c9bc9d6bf4..a7e50d33ff6 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -4,27 +4,24 @@ from __future__ import annotations from datetime import date, datetime, time from aiorecollect.client import PickupType -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_FRIENDLY_NAME, - CONF_NAME, DEVICE_CLASS_TIMESTAMP, ) 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, ) from homeassistant.util.dt import as_utc -from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN ATTR_PICKUP_TYPES = "pickup_types" ATTR_AREA_NAME = "area_name" @@ -32,15 +29,9 @@ ATTR_NEXT_PICKUP_TYPES = "next_pickup_types" ATTR_NEXT_PICKUP_DATE = "next_pickup_date" DEFAULT_ATTRIBUTION = "Pickup data provided by ReCollect Waste" -DEFAULT_NAME = "recollect_waste" +DEFAULT_NAME = "Waste Pickup" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_PLACE_ID): cv.string, - vol.Required(CONF_SERVICE_ID): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) +PLATFORM_SCHEMA = cv.deprecated(DOMAIN) @callback @@ -56,26 +47,6 @@ def async_get_pickup_type_names( ] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import Recollect Waste configuration from YAML.""" - LOGGER.warning( - "Loading ReCollect Waste 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, - ) - ) - - @callback def async_get_utc_midnight(target_date: date) -> datetime: """Get UTC midnight for a given date.""" diff --git a/homeassistant/components/recollect_waste/translations/fr.json b/homeassistant/components/recollect_waste/translations/fr.json index dc62c8f520a..2aef5934e9c 100644 --- a/homeassistant/components/recollect_waste/translations/fr.json +++ b/homeassistant/components/recollect_waste/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_place_or_service_id": "ID de lieu ou de service non valide" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 17215eb9845..1b090c331a7 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import concurrent.futures from datetime import datetime, timedelta import logging @@ -9,7 +10,7 @@ import queue import sqlite3 import threading import time -from typing import Any, Callable, NamedTuple +from typing import Any, NamedTuple from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.exc import SQLAlchemyError @@ -49,7 +50,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util -from . import history, migration, purge, statistics +from . import history, migration, purge, statistics, websocket_api from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX from .models import ( Base, @@ -264,6 +265,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _async_register_services(hass, instance) history.async_setup(hass) statistics.async_setup(hass) + websocket_api.async_setup(hass) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) return await instance.async_db_ready @@ -322,6 +324,19 @@ def _async_register_services(hass, instance): ) +class ClearStatisticsTask(NamedTuple): + """Object to store statistics_ids which for which to remove statistics.""" + + statistic_ids: list[str] + + +class UpdateStatisticsMetadataTask(NamedTuple): + """Object to store statistics_id and unit for update of statistics metadata.""" + + statistic_id: str + unit_of_measurement: str | None + + class PurgeTask(NamedTuple): """Object to store information about purge task.""" @@ -564,11 +579,21 @@ class Recorder(threading.Thread): self.queue.put(PerodicCleanupTask()) @callback - def async_hourly_statistics(self, now): + def async_periodic_statistics(self, now): """Trigger the hourly statistics run.""" start = statistics.get_start_time() self.queue.put(StatisticsTask(start)) + @callback + def async_clear_statistics(self, statistic_ids): + """Clear statistics for a list of statistic_ids.""" + self.queue.put(ClearStatisticsTask(statistic_ids)) + + @callback + def async_update_statistics_metadata(self, statistic_id, unit_of_measurement): + """Update statistics metadata for a statistic_id.""" + self.queue.put(UpdateStatisticsMetadataTask(statistic_id, unit_of_measurement)) + @callback def _async_setup_periodic_tasks(self): """Prepare periodic tasks.""" @@ -581,9 +606,9 @@ class Recorder(threading.Thread): self.hass, self.async_nightly_tasks, hour=4, minute=12, second=0 ) - # Compile hourly statistics every hour at *:12 + # Compile short term statistics every 5 minutes async_track_time_change( - self.hass, self.async_hourly_statistics, minute=12, second=0 + self.hass, self.async_periodic_statistics, minute=range(0, 60, 5), second=10 ) def run(self): @@ -762,6 +787,14 @@ class Recorder(threading.Thread): if isinstance(event, StatisticsTask): self._run_statistics(event.start) return + if isinstance(event, ClearStatisticsTask): + statistics.clear_statistics(self, event.statistic_ids) + return + if isinstance(event, UpdateStatisticsMetadataTask): + statistics.update_statistics_metadata( + self, event.statistic_id, event.unit_of_measurement + ) + return if isinstance(event, WaitTask): self._queue_watch.set() return @@ -994,20 +1027,21 @@ class Recorder(threading.Thread): def _schedule_compile_missing_statistics(self, session: Session) -> None: """Add tasks for missing statistics runs.""" now = dt_util.utcnow() - last_hour = now.replace(minute=0, second=0, microsecond=0) + last_period_minutes = now.minute - now.minute % 5 + last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) start = now - timedelta(days=self.keep_days) start = start.replace(minute=0, second=0, microsecond=0) # Find the newest statistics run, if any if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): - start = max(start, process_timestamp(last_run) + timedelta(hours=1)) + start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) # Add tasks - while start < last_hour: - end = start + timedelta(hours=1) + while start < last_period: + end = start + timedelta(minutes=5) _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) self.queue.put(StatisticsTask(start)) - start = start + timedelta(hours=1) + start = end def _end_session(self): """End the recorder session.""" diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 6c89fef2be3..72f820a0d3b 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -61,12 +61,12 @@ def async_setup(hass): def get_significant_states(hass, *args, **kwargs): - """Wrap _get_significant_states with a sql session.""" + """Wrap get_significant_states_with_session with an sql session.""" with session_scope(hass=hass) as session: - return _get_significant_states(hass, session, *args, **kwargs) + return get_significant_states_with_session(hass, session, *args, **kwargs) -def _get_significant_states( +def get_significant_states_with_session( hass, session, start_time, @@ -80,6 +80,11 @@ def _get_significant_states( """ Return states changes during UTC period start_time - end_time. + entity_ids is an optional iterable of entities to include in the results. + + filters is an optional SQLAlchemy filter which will be applied to the database + queries unless entity_ids is given, in which case its ignored. + Significant states are all states where there is a state change, as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). @@ -240,47 +245,63 @@ def _get_states_with_session( if run is None: return [] - # We have more than one entity to look at (most commonly we want - # all entities,) so we need to do a search on all states since the - # last recorder run started. + # We have more than one entity to look at so we need to do a query on states + # since the last recorder run started. query = session.query(*QUERY_STATES) - most_recent_states_by_date = session.query( - States.entity_id.label("max_entity_id"), - func.max(States.last_updated).label("max_last_updated"), - ).filter( - (States.last_updated >= run.start) & (States.last_updated < utc_point_in_time) - ) - if entity_ids: - most_recent_states_by_date.filter(States.entity_id.in_(entity_ids)) - - most_recent_states_by_date = most_recent_states_by_date.group_by(States.entity_id) - - most_recent_states_by_date = most_recent_states_by_date.subquery() - - most_recent_state_ids = session.query( - func.max(States.state_id).label("max_state_id") - ).join( - most_recent_states_by_date, - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated == most_recent_states_by_date.c.max_last_updated, - ), - ) - - most_recent_state_ids = most_recent_state_ids.group_by(States.entity_id) - - most_recent_state_ids = most_recent_state_ids.subquery() - - query = query.join( - most_recent_state_ids, - States.state_id == most_recent_state_ids.c.max_state_id, - ) - - if entity_ids is not None: - query = query.filter(States.entity_id.in_(entity_ids)) + # We got an include-list of entities, accelerate the query by filtering already + # in the inner query. + most_recent_state_ids = ( + session.query( + func.max(States.state_id).label("max_state_id"), + ) + .filter( + (States.last_updated >= run.start) + & (States.last_updated < utc_point_in_time) + ) + .filter(States.entity_id.in_(entity_ids)) + ) + most_recent_state_ids = most_recent_state_ids.group_by(States.entity_id) + most_recent_state_ids = most_recent_state_ids.subquery() + query = query.join( + most_recent_state_ids, + States.state_id == most_recent_state_ids.c.max_state_id, + ) else: + # We did not get an include-list of entities, query all states in the inner + # query, then filter out unwanted domains as well as applying the custom filter. + # This filtering can't be done in the inner query because the domain column is + # not indexed and we can't control what's in the custom filter. + most_recent_states_by_date = ( + session.query( + States.entity_id.label("max_entity_id"), + func.max(States.last_updated).label("max_last_updated"), + ) + .filter( + (States.last_updated >= run.start) + & (States.last_updated < utc_point_in_time) + ) + .group_by(States.entity_id) + .subquery() + ) + most_recent_state_ids = ( + session.query(func.max(States.state_id).label("max_state_id")) + .join( + most_recent_states_by_date, + and_( + States.entity_id == most_recent_states_by_date.c.max_entity_id, + States.last_updated + == most_recent_states_by_date.c.max_last_updated, + ), + ) + .group_by(States.entity_id) + .subquery() + ) + query = query.join( + most_recent_state_ids, + States.state_id == most_recent_state_ids.c.max_state_id, + ) query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) if filters: query = filters.apply(query) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4a5c456df28..a3d2955e55b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,9 +1,10 @@ """Schema migration helpers.""" +import contextlib from datetime import timedelta import logging import sqlalchemy -from sqlalchemy import ForeignKeyConstraint, MetaData, Table, text +from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text from sqlalchemy.exc import ( InternalError, OperationalError, @@ -12,8 +13,6 @@ from sqlalchemy.exc import ( ) from sqlalchemy.schema import AddConstraint, DropConstraint -import homeassistant.util.dt as dt_util - from .models import ( SCHEMA_VERSION, TABLE_STATES, @@ -22,7 +21,10 @@ from .models import ( Statistics, StatisticsMeta, StatisticsRuns, + StatisticsShortTerm, + process_timestamp, ) +from .statistics import get_metadata_with_session, get_start_time from .util import session_scope _LOGGER = logging.getLogger(__name__) @@ -72,7 +74,7 @@ def migrate_schema(instance, current_version): for version in range(current_version, SCHEMA_VERSION): new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) - _apply_update(instance.engine, session, new_version, current_version) + _apply_update(instance, session, new_version, current_version) session.add(SchemaChanges(schema_version=new_version)) _LOGGER.info("Upgrade to version %s done", new_version) @@ -351,8 +353,9 @@ def _drop_foreign_key_constraints(connection, engine, table, columns): ) -def _apply_update(engine, session, new_version, old_version): # noqa: C901 +def _apply_update(instance, session, new_version, old_version): # noqa: C901 """Perform operations to bring schema up to date.""" + engine = instance.engine connection = session.connection() if new_version == 1: _create_index(connection, "events", "ix_events_time_fired") @@ -467,28 +470,28 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 elif new_version == 18: # Recreate the statistics and statistics meta tables. # - # Order matters! Statistics has a relation with StatisticsMeta, - # so statistics need to be deleted before meta (or in pair depending - # on the SQL backend); and meta needs to be created before statistics. - if sqlalchemy.inspect(engine).has_table( - StatisticsMeta.__tablename__ - ) or sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): - Base.metadata.drop_all( - bind=engine, tables=[Statistics.__table__, StatisticsMeta.__table__] - ) + # Order matters! Statistics and StatisticsShortTerm have a relation with + # StatisticsMeta, so statistics need to be deleted before meta (or in pair + # depending on the SQL backend); and meta needs to be created before statistics. + Base.metadata.drop_all( + bind=engine, + tables=[ + StatisticsShortTerm.__table__, + Statistics.__table__, + StatisticsMeta.__table__, + ], + ) StatisticsMeta.__table__.create(engine) + StatisticsShortTerm.__table__.create(engine) Statistics.__table__.create(engine) elif new_version == 19: # This adds the statistic runs table, insert a fake run to prevent duplicating # statistics. - now = dt_util.utcnow() - start = now.replace(minute=0, second=0, microsecond=0) - start = start - timedelta(hours=1) - session.add(StatisticsRuns(start=start)) + session.add(StatisticsRuns(start=get_start_time())) elif new_version == 20: # This changed the precision of statistics from float to double - if engine.dialect.name in ["mysql", "oracle", "postgresql"]: + if engine.dialect.name in ["mysql", "postgresql"]: _modify_columns( connection, engine, @@ -501,6 +504,81 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 "sum DOUBLE PRECISION", ], ) + elif new_version == 21: + # Try to change the character set of the statistic_meta table + if engine.dialect.name == "mysql": + for table in ("events", "states", "statistics_meta"): + _LOGGER.warning( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!", + table, + ) + with contextlib.suppress(SQLAlchemyError): + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + f"ALTER TABLE {table} CONVERT TO " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci LOCK=EXCLUSIVE" + ) + ) + elif new_version == 22: + # Recreate the all statistics tables for Oracle DB with Identity columns + # + # Order matters! Statistics has a relation with StatisticsMeta, + # so statistics need to be deleted before meta (or in pair depending + # on the SQL backend); and meta needs to be created before statistics. + if engine.dialect.name == "oracle": + Base.metadata.drop_all( + bind=engine, + tables=[ + StatisticsShortTerm.__table__, + Statistics.__table__, + StatisticsMeta.__table__, + StatisticsRuns.__table__, + ], + ) + + StatisticsRuns.__table__.create(engine) + StatisticsMeta.__table__.create(engine) + StatisticsShortTerm.__table__.create(engine) + Statistics.__table__.create(engine) + + # Block 5-minute statistics for one hour from the last run, or it will overlap + # with existing hourly statistics. Don't block on a database with no existing + # statistics. + if session.query(Statistics.id).count() and ( + last_run_string := session.query(func.max(StatisticsRuns.start)).scalar() + ): + last_run_start_time = process_timestamp(last_run_string) + if last_run_start_time: + fake_start_time = last_run_start_time + timedelta(minutes=5) + while fake_start_time < last_run_start_time + timedelta(hours=1): + session.add(StatisticsRuns(start=fake_start_time)) + fake_start_time += timedelta(minutes=5) + + # Copy last hourly statistic to the newly created 5-minute statistics table + sum_statistics = get_metadata_with_session( + instance.hass, session, None, statistic_type="sum" + ) + for metadata_id, _ in sum_statistics.values(): + last_statistic = ( + session.query(Statistics) + .filter_by(metadata_id=metadata_id) + .order_by(Statistics.start.desc()) + .first() + ) + if last_statistic: + session.add( + StatisticsShortTerm( + metadata_id=last_statistic.metadata_id, + start=last_statistic.start, + last_reset=last_statistic.last_reset, + state=last_statistic.state, + sum=last_statistic.sum, + ) + ) else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -520,10 +598,7 @@ def _inspect_schema_version(engine, session): for index in indexes: if index["column_names"] == ["time_fired"]: # Schema addition from version 1 detected. New DB. - now = dt_util.utcnow() - start = now.replace(minute=0, second=0, microsecond=0) - start = start - timedelta(hours=1) - session.add(StatisticsRuns(start=start)) + session.add(StatisticsRuns(start=get_start_time())) session.add(SchemaChanges(schema_version=SCHEMA_VERSION)) return SCHEMA_VERSION diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 28eff4d9d95..a43c7781c8d 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,10 +1,11 @@ """Models for SQLAlchemy.""" from __future__ import annotations -from datetime import datetime +from collections.abc import Iterable +from datetime import datetime, timedelta import json import logging -from typing import TypedDict +from typing import TypedDict, overload from sqlalchemy import ( Boolean, @@ -20,6 +21,7 @@ from sqlalchemy import ( distinct, ) from sqlalchemy.dialects import mysql, oracle, postgresql +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session @@ -39,7 +41,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 20 +SCHEMA_VERSION = 22 _LOGGER = logging.getLogger(__name__) @@ -52,6 +54,7 @@ TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" TABLE_STATISTICS_META = "statistics_meta" TABLE_STATISTICS_RUNS = "statistics_runs" +TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" ALL_TABLES = [ TABLE_STATES, @@ -61,6 +64,7 @@ ALL_TABLES = [ TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, ] DATETIME_TYPE = DateTime(timezone=True).with_variant( @@ -220,7 +224,23 @@ class States(Base): # type: ignore return None -class StatisticData(TypedDict, total=False): +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: Iterable[StatisticData] + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticData(StatisticDataBase, total=False): """Statistic data class.""" mean: float @@ -231,21 +251,21 @@ class StatisticData(TypedDict, total=False): sum: float -class Statistics(Base): # type: ignore - """Statistics.""" +class StatisticsBase: + """Statistics base class.""" - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index("ix_statistics_statistic_id_start", "metadata_id", "start"), - ) - __tablename__ = TABLE_STATISTICS - id = Column(Integer, primary_key=True) + id = Column(Integer, Identity(), primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) - metadata_id = Column( - Integer, - ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), - index=True, - ) + + @declared_attr + def metadata_id(self): + """Define the metadata_id column for sub classes.""" + return Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + start = Column(DATETIME_TYPE, index=True) mean = Column(DOUBLE_TYPE) min = Column(DOUBLE_TYPE) @@ -254,17 +274,40 @@ class Statistics(Base): # type: ignore state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) - @staticmethod - def from_stats(metadata_id: str, start: datetime, stats: StatisticData): + @classmethod + def from_stats(cls, metadata_id: int, stats: StatisticData): """Create object from a statistics.""" - return Statistics( + return cls( # type: ignore metadata_id=metadata_id, - start=start, **stats, ) -class StatisticMetaData(TypedDict, total=False): +class Statistics(Base, StatisticsBase): # type: ignore + """Long term statistics.""" + + duration = timedelta(hours=1) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS + + +class StatisticsShortTerm(Base, StatisticsBase): # type: ignore + """Short term statistics.""" + + duration = timedelta(minutes=5) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_short_term_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + +class StatisticMetaData(TypedDict): """Statistic meta data class.""" statistic_id: str @@ -276,8 +319,11 @@ class StatisticMetaData(TypedDict, total=False): class StatisticsMeta(Base): # type: ignore """Statistics meta data.""" + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) __tablename__ = TABLE_STATISTICS_META - id = Column(Integer, primary_key=True) + id = Column(Integer, Identity(), primary_key=True) statistic_id = Column(String(255), index=True) source = Column(String(32)) unit_of_measurement = Column(String(255)) @@ -374,7 +420,7 @@ class StatisticsRuns(Base): # type: ignore """Representation of statistics run.""" __tablename__ = TABLE_STATISTICS_RUNS - run_id = Column(Integer, primary_key=True) + run_id = Column(Integer, Identity(), primary_key=True) start = Column(DateTime(timezone=True)) def __repr__(self) -> str: @@ -386,7 +432,17 @@ class StatisticsRuns(Base): # type: ignore ) -def process_timestamp(ts): +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + +def process_timestamp(ts: datetime | None) -> datetime | None: """Process a timestamp into datetime object.""" if ts is None: return None @@ -396,6 +452,16 @@ def process_timestamp(ts): return dt_util.as_utc(ts) +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: """Process a timestamp into UTC isotime.""" if ts is None: diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 49803117119..2b84a439871 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,9 +1,10 @@ """Purge old data helper.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import distinct @@ -37,7 +38,8 @@ def purge_old_data( event_ids = _select_event_ids_to_purge(session, purge_before) state_ids = _select_state_ids_to_purge(session, purge_before, event_ids) if state_ids: - _purge_state_ids(session, state_ids) + _purge_state_ids(instance, session, state_ids) + if event_ids: _purge_event_ids(session, event_ids) # If states or events purging isn't processing the purge_before yet, @@ -67,10 +69,10 @@ def _select_event_ids_to_purge(session: Session, purge_before: datetime) -> list def _select_state_ids_to_purge( session: Session, purge_before: datetime, event_ids: list[int] -) -> list[int]: +) -> set[int]: """Return a list of state ids to purge.""" if not event_ids: - return [] + return set() states = ( session.query(States.state_id) .filter(States.last_updated < purge_before) @@ -78,10 +80,10 @@ def _select_state_ids_to_purge( .all() ) _LOGGER.debug("Selected %s state ids to remove", len(states)) - return [state.state_id for state in states] + return {state.state_id for state in states} -def _purge_state_ids(session: Session, state_ids: list[int]) -> None: +def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) -> None: """Disconnect states and delete by state id.""" # Update old_state_id to NULL before deleting to ensure @@ -102,6 +104,26 @@ def _purge_state_ids(session: Session, state_ids: list[int]) -> None: ) _LOGGER.debug("Deleted %s states", deleted_rows) + # Evict eny entries in the old_states cache referring to a purged state + _evict_purged_states_from_old_states_cache(instance, state_ids) + + +def _evict_purged_states_from_old_states_cache( + instance: Recorder, purged_state_ids: set[int] +) -> None: + """Evict purged states from the old states cache.""" + # Make a map from old_state_id to entity_id + old_states = instance._old_states # pylint: disable=protected-access + old_state_reversed = { + old_state.state_id: entity_id + for entity_id, old_state in old_states.items() + if old_state.state_id + } + + # Evict any purged state from the old states cache + for purged_state_id in purged_state_ids.intersection(old_state_reversed): + old_states.pop(old_state_reversed[purged_state_id], None) + def _purge_event_ids(session: Session, event_ids: list[int]) -> None: """Delete by event id.""" @@ -138,7 +160,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if not instance.entity_filter(entity_id) ] if len(excluded_entity_ids) > 0: - _purge_filtered_states(session, excluded_entity_ids) + _purge_filtered_states(instance, session, excluded_entity_ids) return False # Check if excluded event_types are in database @@ -148,13 +170,15 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if event_type in instance.exclude_t ] if len(excluded_event_types) > 0: - _purge_filtered_events(session, excluded_event_types) + _purge_filtered_events(instance, session, excluded_event_types) return False return True -def _purge_filtered_states(session: Session, excluded_entity_ids: list[str]) -> None: +def _purge_filtered_states( + instance: Recorder, session: Session, excluded_entity_ids: list[str] +) -> None: """Remove filtered states and linked events.""" state_ids: list[int] event_ids: list[int | None] @@ -170,11 +194,13 @@ def _purge_filtered_states(session: Session, excluded_entity_ids: list[str]) -> _LOGGER.debug( "Selected %s state_ids to remove that should be filtered", len(state_ids) ) - _purge_state_ids(session, state_ids) + _purge_state_ids(instance, session, set(state_ids)) _purge_event_ids(session, event_ids) # type: ignore # type of event_ids already narrowed to 'list[int]' -def _purge_filtered_events(session: Session, excluded_event_types: list[str]) -> None: +def _purge_filtered_events( + instance: Recorder, session: Session, excluded_event_types: list[str] +) -> None: """Remove filtered events and linked states.""" events: list[Events] = ( session.query(Events.event_id) @@ -189,8 +215,8 @@ def _purge_filtered_events(session: Session, excluded_event_types: list[str]) -> states: list[States] = ( session.query(States.state_id).filter(States.event_id.in_(event_ids)).all() ) - state_ids: list[int] = [state.state_id for state in states] - _purge_state_ids(session, state_ids) + state_ids: set[int] = {state.state_id for state in states} + _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids) @@ -206,7 +232,7 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) _LOGGER.debug("Purging entity data for %s", selected_entity_ids) if len(selected_entity_ids) > 0: # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states or events record - _purge_filtered_states(session, selected_entity_ids) + _purge_filtered_states(instance, session, selected_entity_ids) _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index db82eb1ee39..7b7e349b843 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,12 +2,14 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable, Iterable +import dataclasses from datetime import datetime, timedelta from itertools import groupby import logging -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Literal -from sqlalchemy import bindparam +from sqlalchemy import bindparam, func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session @@ -28,10 +30,14 @@ import homeassistant.util.volume as volume_util from .const import DOMAIN from .models import ( + StatisticData, StatisticMetaData, + StatisticResult, Statistics, StatisticsMeta, StatisticsRuns, + StatisticsShortTerm, + process_timestamp, process_timestamp_to_utc_isoformat, ) from .util import execute, retryable_database_job, session_scope @@ -50,6 +56,38 @@ QUERY_STATISTICS = [ Statistics.sum, ] +QUERY_STATISTICS_SHORT_TERM = [ + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start, + StatisticsShortTerm.mean, + StatisticsShortTerm.min, + StatisticsShortTerm.max, + StatisticsShortTerm.last_reset, + StatisticsShortTerm.state, + StatisticsShortTerm.sum, +] + +QUERY_STATISTICS_SUMMARY_MEAN = [ + StatisticsShortTerm.metadata_id, + func.avg(StatisticsShortTerm.mean), + func.min(StatisticsShortTerm.min), + func.max(StatisticsShortTerm.max), +] + +QUERY_STATISTICS_SUMMARY_SUM = [ + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start, + StatisticsShortTerm.last_reset, + StatisticsShortTerm.state, + StatisticsShortTerm.sum, + func.row_number() + .over( + partition_by=StatisticsShortTerm.metadata_id, + order_by=StatisticsShortTerm.start.desc(), + ) + .label("rownum"), +] + QUERY_STATISTIC_META = [ StatisticsMeta.id, StatisticsMeta.statistic_id, @@ -64,7 +102,9 @@ QUERY_STATISTIC_META_ID = [ ] STATISTICS_BAKERY = "recorder_statistics_bakery" -STATISTICS_META_BAKERY = "recorder_statistics_bakery" +STATISTICS_META_BAKERY = "recorder_statistics_meta_bakery" +STATISTICS_SHORT_TERM_BAKERY = "recorder_statistics_short_term_bakery" + # Convert pressure and temperature statistics from the native unit used for statistics # to the units configured by the user @@ -89,10 +129,23 @@ UNIT_CONVERSIONS = { _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass +class ValidationIssue: + """Error or warning message.""" + + type: str + data: dict[str, str | None] | None = None + + def as_dict(self) -> dict: + """Return dictionary version.""" + return dataclasses.asdict(self) + + def async_setup(hass: HomeAssistant) -> None: """Set up the history hooks.""" hass.data[STATISTICS_BAKERY] = baked.bakery() hass.data[STATISTICS_META_BAKERY] = baked.bakery() + hass.data[STATISTICS_SHORT_TERM_BAKERY] = baked.bakery() def entity_id_changed(event: Event) -> None: """Handle entity_id changed.""" @@ -122,50 +175,42 @@ def async_setup(hass: HomeAssistant) -> None: 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: 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_ID) - ) - baked_query += lambda q: q.filter( - StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) - ) - result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - - return [id for id, _ in result] if result else [] + now = dt_util.utcnow() + current_period_minutes = now.minute - now.minute % 5 + current_period = now.replace(minute=current_period_minutes, second=0, microsecond=0) + last_period = current_period - timedelta(minutes=5) + return last_period def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, - statistic_id: str, new_metadata: StatisticMetaData, -) -> str: - """Get metadata_id for a statistic_id, add if it doesn't exist.""" - old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) +) -> int: + """Get metadata_id for a statistic_id. + + If the statistic_id is previously unknown, add it. If it's already known, update + metadata if needed. + + Updating metadata source is not possible. + """ + statistic_id = new_metadata["statistic_id"] + old_metadata_dict = get_metadata_with_session(hass, session, [statistic_id], None) if not old_metadata_dict: unit = new_metadata["unit_of_measurement"] has_mean = new_metadata["has_mean"] has_sum = new_metadata["has_sum"] - session.add( - StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) - ) - metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + meta = StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) + session.add(meta) + session.flush() # Flush to get the metadata id assigned _LOGGER.debug( "Added new statistics metadata for %s, new_metadata: %s", statistic_id, new_metadata, ) - return metadata_ids[0] + return meta.id # type: ignore[no-any-return] - metadata_id, old_metadata = next(iter(old_metadata_dict.items())) + metadata_id, old_metadata = old_metadata_dict[statistic_id] if ( old_metadata["has_mean"] != new_metadata["has_mean"] or old_metadata["has_sum"] != new_metadata["has_sum"] @@ -189,53 +234,146 @@ def _update_or_add_metadata( return metadata_id +def compile_hourly_statistics( + instance: Recorder, session: scoped_session, start: datetime +) -> None: + """Compile hourly statistics. + + This will summarize 5-minute statistics for one hour: + - average, min max is computed by a database query + - sum is taken from the last 5-minute entry during the hour + """ + start_time = start.replace(minute=0) + end_time = start_time + timedelta(hours=1) + + # Compute last hour's average, min, max + summary: dict[str, StatisticData] = {} + baked_query = instance.hass.data[STATISTICS_SHORT_TERM_BAKERY]( + lambda session: session.query(*QUERY_STATISTICS_SUMMARY_MEAN) + ) + + baked_query += lambda q: q.filter( + StatisticsShortTerm.start >= bindparam("start_time") + ) + baked_query += lambda q: q.filter(StatisticsShortTerm.start < bindparam("end_time")) + baked_query += lambda q: q.group_by(StatisticsShortTerm.metadata_id) + baked_query += lambda q: q.order_by(StatisticsShortTerm.metadata_id) + + stats = execute( + baked_query(session).params(start_time=start_time, end_time=end_time) + ) + + if stats: + for stat in stats: + metadata_id, _mean, _min, _max = stat + summary[metadata_id] = { + "start": start_time, + "mean": _mean, + "min": _min, + "max": _max, + } + + # Get last hour's last sum + subquery = ( + session.query(*QUERY_STATISTICS_SUMMARY_SUM) + .filter(StatisticsShortTerm.start >= bindparam("start_time")) + .filter(StatisticsShortTerm.start < bindparam("end_time")) + .subquery() + ) + query = ( + session.query(subquery) + .filter(subquery.c.rownum == 1) + .order_by(subquery.c.metadata_id) + ) + stats = execute(query.params(start_time=start_time, end_time=end_time)) + + if stats: + for stat in stats: + metadata_id, start, last_reset, state, _sum, _ = stat + if metadata_id in summary: + summary[metadata_id].update( + { + "last_reset": process_timestamp(last_reset), + "state": state, + "sum": _sum, + } + ) + else: + summary[metadata_id] = { + "start": start_time, + "last_reset": process_timestamp(last_reset), + "state": state, + "sum": _sum, + } + + # Insert compiled hourly statistics in the database + for metadata_id, stat in summary.items(): + session.add(Statistics.from_stats(metadata_id, stat)) + + @retryable_database_job("statistics") def compile_statistics(instance: Recorder, start: datetime) -> bool: - """Compile statistics.""" - start = dt_util.as_utc(start) - end = start + timedelta(hours=1) + """Compile 5-minute statistics for all integrations with a recorder platform. + The actual calculation is delegated to the platforms. + """ + start = dt_util.as_utc(start) + end = start + timedelta(minutes=5) + + # Return if we already have 5-minute statistics for the requested period with session_scope(session=instance.get_session()) as session: # type: ignore if session.query(StatisticsRuns).filter_by(start=start).first(): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) return True _LOGGER.debug("Compiling statistics for %s-%s", start, end) - platform_stats = [] + platform_stats: list[StatisticResult] = [] + # Collect statistics from all platforms implementing support for domain, platform in instance.hass.data[DOMAIN].items(): if not hasattr(platform, "compile_statistics"): continue - platform_stats.append(platform.compile_statistics(instance.hass, start, end)) + platform_stat = platform.compile_statistics(instance.hass, start, end) _LOGGER.debug( - "Statistics for %s during %s-%s: %s", domain, start, end, platform_stats[-1] + "Statistics for %s during %s-%s: %s", domain, start, end, platform_stat ) + platform_stats.extend(platform_stat) + # Insert collected statistics in the database with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: - for entity_id, stat in stats.items(): - metadata_id = _update_or_add_metadata( - instance.hass, session, entity_id, stat["meta"] - ) + metadata_id = _update_or_add_metadata(instance.hass, session, stats["meta"]) + for stat in stats["stat"]: try: - session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + session.add(StatisticsShortTerm.from_stats(metadata_id, stat)) except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", metadata_id, - stat, + stats, ) + + if start.minute == 55: + # A full hour is ready, summarize it + compile_hourly_statistics(instance, session, start) + session.add(StatisticsRuns(start=start)) return True -def _get_metadata( +def get_metadata_with_session( hass: HomeAssistant, session: scoped_session, - statistic_ids: list[str] | None, - statistic_type: str | None, -) -> dict[str, StatisticMetaData]: - """Fetch meta data.""" + statistic_ids: Iterable[str] | None, + statistic_type: Literal["mean"] | Literal["sum"] | None, +) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data. + + Returns a dict of (metadata_id, StatisticMetaData) indexed by statistic_id. + + If statistic_ids is given, fetch metadata only for the listed statistics_ids. + If statistic_type is given, fetch metadata only for statistic_ids supporting it. + """ def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None: meta: StatisticMetaData | None = None @@ -249,6 +387,7 @@ def _get_metadata( } return meta + # Fetch metatadata from the database baked_query = hass.data[STATISTICS_META_BAKERY]( lambda session: session.query(*QUERY_STATISTIC_META) ) @@ -260,32 +399,27 @@ def _get_metadata( baked_query += lambda q: q.filter(StatisticsMeta.has_mean.isnot(False)) 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] - metadata: dict[str, StatisticMetaData] = {} + # Prepare the result dict + metadata: dict[str, tuple[int, StatisticMetaData]] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: - metadata[_id] = meta + metadata[meta["statistic_id"]] = (_id, meta) return metadata def get_metadata( hass: HomeAssistant, - statistic_id: str, -) -> StatisticMetaData | None: - """Return metadata for a statistic_id.""" - statistic_ids = [statistic_id] + statistic_ids: Iterable[str], +) -> dict[str, tuple[int, StatisticMetaData]]: + """Return metadata for statistic_ids.""" with session_scope(hass=hass) as session: - metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_ids: - return None - return _get_metadata(hass, session, statistic_ids, None).get(metadata_ids[0]) + return get_metadata_with_session(hass, session, statistic_ids, None) def _configured_unit(unit: str, units: UnitSystem) -> str: @@ -301,26 +435,54 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: return unit +def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: + """Clear statistics for a list of statistic_ids.""" + with session_scope(session=instance.get_session()) as session: # type: ignore + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id.in_(statistic_ids) + ).delete(synchronize_session=False) + + +def update_statistics_metadata( + instance: Recorder, statistic_id: str, unit_of_measurement: str | None +) -> None: + """Update statistics metadata for a statistic_id.""" + with session_scope(session=instance.get_session()) as session: # type: ignore + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id == statistic_id + ).update({StatisticsMeta.unit_of_measurement: unit_of_measurement}) + + def list_statistic_ids( - hass: HomeAssistant, statistic_type: str | None = None -) -> list[StatisticMetaData | None]: - """Return statistic_ids and meta data.""" + hass: HomeAssistant, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, +) -> list[dict | None]: + """Return all statistic_ids and unit of measurement. + + Queries the database for existing statistic_ids, as well as integrations with + a recorder platform for statistic_ids which will be added in the next statistics + period. + """ units = hass.config.units statistic_ids = {} - with session_scope(hass=hass) as session: - metadata = _get_metadata(hass, session, None, statistic_type) - for meta in metadata.values(): + # Query the database + with session_scope(hass=hass) as session: + metadata = get_metadata_with_session(hass, session, None, statistic_type) + + for _, meta in metadata.values(): unit = meta["unit_of_measurement"] if unit is not None: + # Display unit according to user settings unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit statistic_ids = { meta["statistic_id"]: meta["unit_of_measurement"] - for meta in metadata.values() + for _, meta in metadata.values() } + # Query all integrations with a registered recorder platform for platform in hass.data[DOMAIN].values(): if not hasattr(platform, "list_statistic_ids"): continue @@ -328,47 +490,83 @@ def list_statistic_ids( for statistic_id, unit in platform_statistic_ids.items(): if unit is not None: + # Display unit according to user settings unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit - statistic_ids = {**statistic_ids, **platform_statistic_ids} + for key, value in platform_statistic_ids.items(): + statistic_ids.setdefault(key, value) + # Return a map of statistic_id to unit_of_measurement return [ {"statistic_id": _id, "unit_of_measurement": unit} for _id, unit in statistic_ids.items() ] +def _statistics_during_period_query( + hass: HomeAssistant, + end_time: datetime | None, + statistic_ids: list[str] | None, + bakery: Any, + base_query: Iterable, + table: type[Statistics | StatisticsShortTerm], +) -> Callable: + """Prepare a database query for statistics during a given period. + + This prepares a baked query, so we don't insert the parameters yet. + """ + baked_query = hass.data[bakery](lambda session: session.query(*base_query)) + + baked_query += lambda q: q.filter(table.start >= bindparam("start_time")) + + if end_time is not None: + baked_query += lambda q: q.filter(table.start < bindparam("end_time")) + + if statistic_ids is not None: + baked_query += lambda q: q.filter( + table.metadata_id.in_(bindparam("metadata_ids")) + ) + + baked_query += lambda q: q.order_by(table.metadata_id, table.start) + return baked_query # type: ignore[no-any-return] + + def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None = None, statistic_ids: list[str] | None = None, + period: Literal["hour"] | Literal["5minute"] = "hour", ) -> dict[str, list[dict[str, str]]]: - """Return states changes during UTC period start_time - end_time.""" + """Return statistics during UTC period start_time - end_time for the statistic_ids. + + If end_time is omitted, returns statistics newer than or equal to start_time. + If statistic_ids is omitted, returns statistics for all statistics ids. + """ metadata = None with session_scope(hass=hass) as session: - metadata = _get_metadata(hass, session, statistic_ids, None) + # Fetch metadata for the given (or all) statistic_ids + metadata = get_metadata_with_session(hass, session, statistic_ids, None) if not metadata: return {} - baked_query = hass.data[STATISTICS_BAKERY]( - lambda session: session.query(*QUERY_STATISTICS) - ) - - baked_query += lambda q: q.filter(Statistics.start >= bindparam("start_time")) - - if end_time is not None: - baked_query += lambda q: q.filter(Statistics.start < bindparam("end_time")) - metadata_ids = None if statistic_ids is not None: - baked_query += lambda q: q.filter( - Statistics.metadata_id.in_(bindparam("metadata_ids")) - ) - metadata_ids = list(metadata.keys()) + metadata_ids = [metadata_id for metadata_id, _ in metadata.values()] - baked_query += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) + if period == "hour": + bakery = STATISTICS_BAKERY + base_query = QUERY_STATISTICS + table = Statistics + else: + bakery = STATISTICS_SHORT_TERM_BAKERY + base_query = QUERY_STATISTICS_SHORT_TERM + table = StatisticsShortTerm + + baked_query = _statistics_during_period_query( + hass, end_time, statistic_ids, bakery, base_query, table + ) stats = execute( baked_query(session).params( @@ -377,28 +575,32 @@ def statistics_during_period( ) if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True) + # Return statistics combined with metadata + return _sorted_statistics_to_dict( + hass, stats, statistic_ids, metadata, True, table.duration + ) def get_last_statistics( hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool ) -> dict[str, list[dict]]: - """Return the last number_of_stats statistics for a statistic_id.""" + """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass) as session: - metadata = _get_metadata(hass, session, statistic_ids, None) + # Fetch metadata for the given statistic_id + metadata = get_metadata_with_session(hass, session, statistic_ids, None) if not metadata: return {} - baked_query = hass.data[STATISTICS_BAKERY]( - lambda session: session.query(*QUERY_STATISTICS) + baked_query = hass.data[STATISTICS_SHORT_TERM_BAKERY]( + lambda session: session.query(*QUERY_STATISTICS_SHORT_TERM) ) baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) - metadata_id = next(iter(metadata.keys())) + metadata_id = metadata[statistic_id][0] baked_query += lambda q: q.order_by( - Statistics.metadata_id, Statistics.start.desc() + StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc() ) baked_query += lambda q: q.limit(bindparam("number_of_stats")) @@ -411,8 +613,14 @@ def get_last_statistics( if not stats: return {} + # Return statistics combined with metadata return _sorted_statistics_to_dict( - hass, stats, statistic_ids, metadata, convert_units + hass, + stats, + statistic_ids, + metadata, + convert_units, + StatisticsShortTerm.duration, ) @@ -420,8 +628,9 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[str, StatisticMetaData], + _metadata: dict[str, tuple[int, StatisticMetaData]], convert_units: bool, + duration: timedelta, ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) @@ -436,10 +645,9 @@ def _sorted_statistics_to_dict( for stat_id in statistic_ids: result[stat_id] = [] - # Called in a tight loop so cache the function here - _process_timestamp_to_utc_isoformat = process_timestamp_to_utc_isoformat + metadata = dict(_metadata.values()) - # Append all statistic entries, and do unit conversion + # Append all statistic entries, and optionally do unit conversion 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"] @@ -449,19 +657,34 @@ def _sorted_statistics_to_dict( else: convert = no_conversion ent_results = result[meta_id] - ent_results.extend( - { - "statistic_id": statistic_id, - "start": _process_timestamp_to_utc_isoformat(db_state.start), - "mean": convert(db_state.mean, units), - "min": convert(db_state.min, units), - "max": convert(db_state.max, units), - "last_reset": _process_timestamp_to_utc_isoformat(db_state.last_reset), - "state": convert(db_state.state, units), - "sum": convert(db_state.sum, units), - } - for db_state in group - ) + for db_state in group: + start = process_timestamp(db_state.start) + end = start + duration + ent_results.append( + { + "statistic_id": statistic_id, + "start": start.isoformat(), + "end": end.isoformat(), + "mean": convert(db_state.mean, units), + "min": convert(db_state.min, units), + "max": convert(db_state.max, units), + "last_reset": process_timestamp_to_utc_isoformat( + db_state.last_reset + ), + "state": convert(db_state.state, units), + "sum": convert(db_state.sum, units), + } + ) # Filter out the empty lists if some states had 0 results. return {metadata[key]["statistic_id"]: val for key, val in result.items() if val} + + +def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]: + """Validate statistics.""" + platform_validation: dict[str, list[ValidationIssue]] = {} + for platform in hass.data[DOMAIN].values(): + if not hasattr(platform, "validate_statistics"): + continue + platform_validation.update(platform.validate_statistics(hass)) + return platform_validation diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f492b754125..101915c7117 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,14 +1,14 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from datetime import timedelta import functools import logging import os import time -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from sqlalchemy import text from sqlalchemy.exc import OperationalError, SQLAlchemyError @@ -25,6 +25,7 @@ from .models import ( TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, RecorderRuns, process_timestamp, ) @@ -185,7 +186,12 @@ def basic_sanity_check(cursor): for table in ALL_TABLES: # The statistics tables may not be present in old databases - if table in [TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS]: + if table in [ + TABLE_STATISTICS, + TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, + ]: continue if table in (TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES): cursor.execute(f"SELECT * FROM {table};") # nosec # not injection @@ -278,6 +284,9 @@ def setup_connection_for_dialect(dialect_name, dbapi_connection, first_connectio # approximately 8MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -8192") + # enable support for foreign keys + execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") + if dialect_name == "mysql": execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py new file mode 100644 index 00000000000..ba77692fe8e --- /dev/null +++ b/homeassistant/components/recorder/websocket_api.py @@ -0,0 +1,74 @@ +"""The Energy websocket API.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_INSTANCE +from .statistics import validate_statistics + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the recorder websocket API.""" + websocket_api.async_register_command(hass, ws_validate_statistics) + websocket_api.async_register_command(hass, ws_clear_statistics) + websocket_api.async_register_command(hass, ws_update_statistics_metadata) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/validate_statistics", + } +) +@websocket_api.async_response +async def ws_validate_statistics( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Fetch a list of available statistic_id.""" + statistic_ids = await hass.async_add_executor_job( + validate_statistics, + hass, + ) + connection.send_result(msg["id"], statistic_ids) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/clear_statistics", + vol.Required("statistic_ids"): [str], + } +) +@callback +def ws_clear_statistics( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Clear statistics for a list of statistic_ids. + + Note: The WS call posts a job to the recorder's queue and then returns, it doesn't + wait until the job is completed. + """ + hass.data[DATA_INSTANCE].async_clear_statistics(msg["statistic_ids"]) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/update_statistics_metadata", + vol.Required("statistic_id"): str, + vol.Required("unit_of_measurement"): vol.Any(str, None), + } +) +@callback +def ws_update_statistics_metadata( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Update statistics metadata for a statistic_id.""" + hass.data[DATA_INSTANCE].async_update_statistics_metadata( + msg["statistic_id"], msg["unit_of_measurement"] + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json index 0b5f539bcce..631414ad344 100644 --- a/homeassistant/components/reddit/manifest.json +++ b/homeassistant/components/reddit/manifest.json @@ -2,7 +2,7 @@ "domain": "reddit", "name": "Reddit", "documentation": "https://www.home-assistant.io/integrations/reddit", - "requirements": ["praw==7.2.0"], + "requirements": ["praw==7.4.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/remote/device_trigger.py b/homeassistant/components/remote/device_trigger.py index 40182cc0114..cf3a7427745 100644 --- a/homeassistant/components/remote/device_trigger.py +++ b/homeassistant/components/remote/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -22,7 +25,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index d4c065e52ca..17e4d3dd82b 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -4,10 +4,11 @@ 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 homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub +from .services import SERVICE_AC_START, setup_services, unload_services async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -21,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryNotReady() from exc if not login_success: - return False + raise ConfigEntryAuthFailed() hass.data.setdefault(DOMAIN, {}) await renault_hub.async_initialise(config_entry) @@ -30,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + if not hass.services.has_service(DOMAIN, SERVICE_AC_START): + setup_services(hass) + return True @@ -41,5 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + unload_services(hass) return unload_ok diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index dd3ccb036e0..2799289fc1d 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -1,22 +1,44 @@ """Support for Renault binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import KamereonVehicleBatteryStatusData from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_PLUG, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultBatteryDataEntity, RenaultDataEntity +from .renault_entities import RenaultDataEntity, RenaultEntityDescription from .renault_hub import RenaultHub +@dataclass +class RenaultBinarySensorRequiredKeysMixin: + """Mixin for required keys.""" + + on_key: str + on_value: StateType + + +@dataclass +class RenaultBinarySensorEntityDescription( + BinarySensorEntityDescription, + RenaultEntityDescription, + RenaultBinarySensorRequiredKeysMixin, +): + """Class describing Renault binary sensor entities.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -24,35 +46,46 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[RenaultDataEntity] = [] - for vehicle in proxy.vehicles.values(): - if "battery" in vehicle.coordinators: - entities.append(RenaultPluggedInSensor(vehicle, "Plugged In")) - entities.append(RenaultChargingSensor(vehicle, "Charging")) + entities: list[RenaultBinarySensor] = [ + RenaultBinarySensor(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in BINARY_SENSOR_TYPES + if description.coordinator in vehicle.coordinators + ] async_add_entities(entities) -class RenaultPluggedInSensor(RenaultBatteryDataEntity, BinarySensorEntity): - """Plugged In binary sensor.""" +class RenaultBinarySensor( + RenaultDataEntity[KamereonVehicleBatteryStatusData], BinarySensorEntity +): + """Mixin for binary sensor specific attributes.""" - _attr_device_class = DEVICE_CLASS_PLUG + entity_description: RenaultBinarySensorEntityDescription @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - if (not self.data) or (self.data.plugStatus is None): - return None - return self.data.get_plug_status() == PlugState.PLUGGED + return ( + self._get_data_attr(self.entity_description.on_key) + == self.entity_description.on_value + ) -class RenaultChargingSensor(RenaultBatteryDataEntity, BinarySensorEntity): - """Charging binary sensor.""" - - _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - if (not self.data) or (self.data.chargingStatus is None): - return None - return self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS +BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( + RenaultBinarySensorEntityDescription( + key="plugged_in", + coordinator="battery", + device_class=DEVICE_CLASS_PLUG, + name="Plugged In", + on_key="plugStatus", + on_value=PlugState.PLUGGED.value, + ), + RenaultBinarySensorEntityDescription( + key="charging", + coordinator="battery", + device_class=DEVICE_CLASS_BATTERY_CHARGING, + name="Charging", + on_key="chargingStatus", + on_value=ChargeState.CHARGE_IN_PROGRESS.value, + ), +) diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 09a69f1f95f..47832cdbe93 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure Renault component.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from renault_api.const import AVAILABLE_LOCALES import voluptuous as vol @@ -21,6 +21,7 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Renault config flow.""" + self._original_data: dict[str, Any] | None = None self.renault_config: dict[str, Any] = {} self.renault_hub: RenaultHub | None = None @@ -90,3 +91,51 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): {vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)} ), ) + + async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._original_data = user_input.copy() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if not user_input: + return self._show_reauth_confirm_form() + + if TYPE_CHECKING: + assert self._original_data + + # Check credentials + self.renault_hub = RenaultHub(self.hass, self._original_data[CONF_LOCALE]) + if not await self.renault_hub.attempt_login( + self._original_data[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + return self._show_reauth_confirm_form({"base": "invalid_credentials"}) + + # Update existing entry + data = {**self._original_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} + existing_entry = await self.async_set_unique_id( + self._original_data[CONF_KAMEREON_ACCOUNT_ID] + ) + if TYPE_CHECKING: + assert existing_entry + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + def _show_reauth_confirm_form( + self, errors: dict[str, Any] | None = None + ) -> FlowResult: + """Show the API keys form.""" + if TYPE_CHECKING: + assert self._original_data + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors or {}, + description_placeholders={ + CONF_USERNAME: self._original_data[CONF_USERNAME] + }, + ) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 0987d1829ed..4c1376288f0 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,4 +1,9 @@ """Constants for the Renault component.""" +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + DOMAIN = "renault" CONF_LOCALE = "locale" @@ -7,8 +12,10 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ - "binary_sensor", - "sensor", + BINARY_SENSOR_DOMAIN, + DEVICE_TRACKER_DOMAIN, + SELECT_DOMAIN, + SENSOR_DOMAIN, ] DEVICE_CLASS_PLUG_STATE = "renault__plug_state" diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py new file mode 100644 index 00000000000..466a1f9e4a6 --- /dev/null +++ b/homeassistant/components/renault/device_tracker.py @@ -0,0 +1,61 @@ +"""Support for Renault device trackers.""" +from __future__ import annotations + +from renault_api.kamereon.models import KamereonVehicleLocationData + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_hub import RenaultHub + + +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.entry_id] + entities: list[RenaultDeviceTracker] = [ + RenaultDeviceTracker(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in DEVICE_TRACKER_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultDeviceTracker( + RenaultDataEntity[KamereonVehicleLocationData], TrackerEntity +): + """Mixin for device tracker specific attributes.""" + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.coordinator.data.gpsLatitude if self.coordinator.data else None + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.coordinator.data.gpsLongitude if self.coordinator.data else None + + @property + def source_type(self) -> str: + """Return the source type of the device.""" + return SOURCE_TYPE_GPS + + +DEVICE_TRACKER_TYPES: tuple[RenaultEntityDescription, ...] = ( + RenaultEntityDescription( + key="location", + coordinator="location", + icon="mdi:car", + name="Location", + ), +) diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py index b47a8507030..4487d9db9ab 100644 --- a/homeassistant/components/renault/renault_coordinator.py +++ b/homeassistant/components/renault/renault_coordinator.py @@ -1,21 +1,22 @@ """Proxy to handle account communication with Renault servers.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import Callable, TypeVar +from typing import TypeVar from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, ) +from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -T = TypeVar("T") +T = TypeVar("T", bound=KamereonVehicleDataAttributes) class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 9188a1f0757..e0aae72298b 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -1,103 +1,73 @@ """Base classes for Renault entities.""" from __future__ import annotations -from typing import Any, Generic, Optional, TypeVar +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, cast -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.entity import Entity, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import slugify +from homeassistant.util.dt import as_utc, parse_datetime +from .renault_coordinator import T from .renault_vehicle import RenaultVehicleProxy + +@dataclass +class RenaultRequiredKeysMixin: + """Mixin for required keys.""" + + coordinator: str + + +@dataclass +class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin): + """Class describing Renault entities.""" + + ATTR_LAST_UPDATE = "last_update" -T = TypeVar("T") - -class RenaultDataEntity(Generic[T], CoordinatorEntity[Optional[T]], Entity): +class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): """Implementation of a Renault entity with a data coordinator.""" + entity_description: RenaultEntityDescription + def __init__( - self, vehicle: RenaultVehicleProxy, entity_type: str, coordinator_key: str + self, + vehicle: RenaultVehicleProxy, + description: RenaultEntityDescription, ) -> None: """Initialise entity.""" - super().__init__(vehicle.coordinators[coordinator_key]) + super().__init__(vehicle.coordinators[description.coordinator]) self.vehicle = vehicle - self._entity_type = entity_type + self.entity_description = description 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}" - ) + self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() + + def _get_data_attr(self, key: str) -> StateType: + """Return the attribute value from the coordinator data.""" + if self.coordinator.data is None: + return None + return cast(StateType, getattr(self.coordinator.data, key)) @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]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """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 - ) + last_update: str | None = None + if self.entity_description.coordinator == "battery": + last_update = cast(str, self._get_data_attr("timestamp")) + elif self.entity_description.coordinator == "location": + last_update = cast(str, self._get_data_attr("lastUpdateTime")) + if last_update: + return {ATTR_LAST_UPDATE: _convert_to_utc_string(last_update)} + return None -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") +def _convert_to_utc_string(value: str) -> str: + """Convert date to UTC iso format.""" + original_dt = parse_datetime(value) + if TYPE_CHECKING: + assert original_dt is not None + return as_utc(original_dt).isoformat() diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 8d4cfea53ee..12f5f4e8671 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from datetime import timedelta import logging from typing import cast @@ -25,6 +27,20 @@ from .renault_coordinator import RenaultDataUpdateCoordinator LOGGER = logging.getLogger(__name__) +@dataclass +class RenaultCoordinatorDescription: + """Class describing Renault coordinators.""" + + endpoint: str + key: str + update_method: Callable[ + [RenaultVehicle], + Callable[[], Awaitable[models.KamereonVehicleDataAttributes]], + ] + # Optional keys + requires_electricity: bool = False + + class RenaultVehicleProxy: """Handle vehicle communication with Renault servers.""" @@ -60,49 +76,29 @@ class RenaultVehicleProxy: """Return a device description for device registry.""" return self._device_info + @property + def vehicle(self) -> RenaultVehicle: + """Return the underlying vehicle.""" + return self._vehicle + async def async_initialise(self) -> None: - """Load available sensors.""" - if await self.endpoint_available("cockpit"): - self.coordinators["cockpit"] = RenaultDataUpdateCoordinator( + """Load available coordinators.""" + self.coordinators = { + coord.key: RenaultDataUpdateCoordinator( self.hass, LOGGER, # Name of the data. For logging purposes. - name=f"{self.details.vin} cockpit", - update_method=self.get_cockpit, + name=f"{self.details.vin} {coord.key}", + update_method=coord.update_method(self._vehicle), # 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, + for coord in COORDINATORS + if ( + self.details.supports_endpoint(coord.endpoint) + and (not coord.requires_electricity or self.details.uses_electricity()) ) - 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( *( @@ -130,24 +126,33 @@ class RenaultVehicleProxy: ) 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() +COORDINATORS: tuple[RenaultCoordinatorDescription, ...] = ( + RenaultCoordinatorDescription( + endpoint="cockpit", + key="cockpit", + update_method=lambda x: x.get_cockpit, + ), + RenaultCoordinatorDescription( + endpoint="hvac-status", + key="hvac_status", + update_method=lambda x: x.get_hvac_status, + ), + RenaultCoordinatorDescription( + endpoint="location", + key="location", + update_method=lambda x: x.get_location, + ), + RenaultCoordinatorDescription( + endpoint="battery-status", + key="battery", + requires_electricity=True, + update_method=lambda x: x.get_battery_status, + ), + RenaultCoordinatorDescription( + endpoint="charge-mode", + key="charge_mode", + requires_electricity=True, + update_method=lambda x: x.get_charge_mode, + ), +) diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py new file mode 100644 index 00000000000..a8f4a15dc21 --- /dev/null +++ b/homeassistant/components/renault/select.py @@ -0,0 +1,102 @@ +"""Support for Renault sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from renault_api.kamereon.models import KamereonVehicleBatteryStatusData + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_CLASS_CHARGE_MODE, DOMAIN +from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_hub import RenaultHub + + +@dataclass +class RenaultSelectRequiredKeysMixin: + """Mixin for required keys.""" + + data_key: str + icon_lambda: Callable[[RenaultSelectEntity], str] + options: list[str] + + +@dataclass +class RenaultSelectEntityDescription( + SelectEntityDescription, RenaultEntityDescription, RenaultSelectRequiredKeysMixin +): + """Class describing Renault select entities.""" + + +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.entry_id] + entities: list[RenaultSelectEntity] = [ + RenaultSelectEntity(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in SENSOR_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultSelectEntity( + RenaultDataEntity[KamereonVehicleBatteryStatusData], SelectEntity +): + """Mixin for sensor specific attributes.""" + + entity_description: RenaultSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return cast(str, self.data) + + @property + def data(self) -> StateType: + """Return the state of this entity.""" + return self._get_data_attr(self.entity_description.data_key) + + @property + def icon(self) -> str | None: + """Icon handling.""" + return self.entity_description.icon_lambda(self) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.vehicle.vehicle.set_charge_mode(option) + + +def _get_charge_mode_icon(entity: RenaultSelectEntity) -> str: + """Return the icon of this entity.""" + if entity.data == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( + RenaultSelectEntityDescription( + key="charge_mode", + coordinator="charge_mode", + data_key="chargeMode", + device_class=DEVICE_CLASS_CHARGE_MODE, + icon_lambda=_get_charge_mode_icon, + name="Charge Mode", + options=["always", "always_charging", "schedule_mode"], + ), +) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 7ef11fb2afc..bcdb01a05f3 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,13 +1,31 @@ """Support for Renault sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleCockpitData, + KamereonVehicleHvacStatusData, +) + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, @@ -18,24 +36,33 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -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 .const import DEVICE_CLASS_CHARGE_STATE, DEVICE_CLASS_PLUG_STATE, DOMAIN +from .renault_coordinator import T +from .renault_entities import RenaultDataEntity, RenaultEntityDescription from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy -ATTR_BATTERY_AVAILABLE_ENERGY = "battery_available_energy" + +@dataclass +class RenaultSensorRequiredKeysMixin: + """Mixin for required keys.""" + + data_key: str + entity_class: type[RenaultSensor] + + +@dataclass +class RenaultSensorEntityDescription( + SensorEntityDescription, RenaultEntityDescription, RenaultSensorRequiredKeysMixin +): + """Class describing Renault sensor entities.""" + + icon_lambda: Callable[[RenaultSensor[T]], str] | None = None + condition_lambda: Callable[[RenaultVehicleProxy], bool] | None = None + requires_fuel: bool = False + value_lambda: Callable[[RenaultSensor[T]], StateType] | None = None async def async_setup_entry( @@ -45,224 +72,219 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] - entities = get_entities(proxy) + entities: list[RenaultSensor] = [ + description.entity_class(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in SENSOR_TYPES + if description.coordinator in vehicle.coordinators + and (not description.requires_fuel or vehicle.details.uses_fuel()) + and (not description.condition_lambda or description.condition_lambda(vehicle)) + ] async_add_entities(entities) -def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: - """Create Renault entities for all vehicles.""" - entities = [] - for vehicle in proxy.vehicles.values(): - entities.extend(get_vehicle_entities(vehicle)) - return entities +class RenaultSensor(RenaultDataEntity[T], SensorEntity): + """Mixin for sensor specific attributes.""" - -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( - RenaultBatteryAvailableEnergySensor(vehicle, "Battery Available Energy") - ) - 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_native_unit_of_measurement = LENGTH_KILOMETERS + entity_description: RenaultSensorEntityDescription @property - def native_value(self) -> int | None: + def data(self) -> StateType: """Return the state of this entity.""" - return self.data.batteryAutonomy if self.data else None - - -class RenaultBatteryAvailableEnergySensor(RenaultBatteryDataEntity, SensorEntity): - """Battery available energy sensor.""" - - _attr_device_class = DEVICE_CLASS_ENERGY - _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + return self._get_data_attr(self.entity_description.data_key) @property - def native_value(self) -> float | None: - """Return the state of this entity.""" - return self.data.batteryAvailableEnergy if self.data else None - - -class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): - """Battery Level sensor.""" - - _attr_device_class = DEVICE_CLASS_BATTERY - _attr_native_unit_of_measurement = PERCENTAGE - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - return self.data.batteryLevel if self.data else None - - -class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): - """Battery Temperature sensor.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS - - @property - def native_value(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 native_value(self) -> str | None: - """Return the state of this entity.""" - return self.data.chargeMode if self.data else None - - @property - def icon(self) -> str: + def icon(self) -> str | None: """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 + if self.entity_description.icon_lambda is None: + return super().icon + return self.entity_description.icon_lambda(self) @property - def native_value(self) -> str | None: + def native_value(self) -> StateType: """Return the state of this entity.""" - charging_status = self.data.get_charging_status() if self.data else None - return charging_status.name.lower() 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_native_unit_of_measurement = TIME_MINUTES - - @property - def native_value(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_POWER - _attr_native_unit_of_measurement = POWER_KILO_WATT - - @property - def native_value(self) -> float | None: - """Return the state of this entity.""" - if not self.data or self.data.chargingInstantaneousPower is None: + if self.data 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 + if self.entity_description.value_lambda is None: + return self.data + return self.entity_description.value_lambda(self) -class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): - """Fuel autonomy sensor.""" - - _attr_icon = "mdi:gas-station" - _attr_native_unit_of_measurement = LENGTH_KILOMETERS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - if not self.data or self.data.fuelAutonomy is None: - return None - return round(self.data.fuelAutonomy) +def _get_charging_power(entity: RenaultSensor[T]) -> StateType: + """Return the charging_power of this entity.""" + return cast(float, entity.data) / 1000 -class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): - """Fuel quantity sensor.""" - - _attr_icon = "mdi:fuel" - _attr_native_unit_of_measurement = VOLUME_LITERS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - if not self.data or self.data.fuelQuantity is None: - return None - return round(self.data.fuelQuantity) +def _get_charge_state_formatted(entity: RenaultSensor[T]) -> str | None: + """Return the charging_status of this entity.""" + data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) + charging_status = data.get_charging_status() if data else None + return charging_status.name.lower() if charging_status else None -class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): - """Mileage sensor.""" - - _attr_icon = "mdi:sign-direction" - _attr_native_unit_of_measurement = LENGTH_KILOMETERS - - @property - def native_value(self) -> int | None: - """Return the state of this entity.""" - if not self.data or self.data.totalMileage is None: - return None - return round(self.data.totalMileage) +def _get_charge_state_icon(entity: RenaultSensor[T]) -> str: + """Return the icon of this entity.""" + if entity.data == ChargeState.CHARGE_IN_PROGRESS.value: + return "mdi:flash" + return "mdi:flash-off" -class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): - """HVAC Outside Temperature sensor.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS - - @property - def native_value(self) -> float | None: - """Return the state of this entity.""" - return self.data.externalTemperature if self.data else None +def _get_plug_state_formatted(entity: RenaultSensor[T]) -> str | None: + """Return the plug_status of this entity.""" + data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) + plug_status = data.get_plug_status() if data else None + return plug_status.name.lower() if plug_status else None -class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): - """Plug State sensor.""" +def _get_plug_state_icon(entity: RenaultSensor[T]) -> str: + """Return the icon of this entity.""" + if entity.data == PlugState.PLUGGED.value: + return "mdi:power-plug" + return "mdi:power-plug-off" - _attr_device_class = DEVICE_CLASS_PLUG_STATE - @property - def native_value(self) -> str | None: - """Return the state of this entity.""" - plug_status = self.data.get_plug_status() if self.data else None - return plug_status.name.lower() if plug_status is not None else None +def _get_rounded_value(entity: RenaultSensor[T]) -> float: + """Return the icon of this entity.""" + return round(cast(float, entity.data)) - @property - def icon(self) -> str: - """Icon handling.""" - return "mdi:power-plug" if self.is_plugged_in else "mdi:power-plug-off" + +SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( + RenaultSensorEntityDescription( + key="battery_level", + coordinator="battery", + data_key="batteryLevel", + device_class=DEVICE_CLASS_BATTERY, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charge_state", + coordinator="battery", + data_key="chargingStatus", + device_class=DEVICE_CLASS_CHARGE_STATE, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon_lambda=_get_charge_state_icon, + name="Charge State", + value_lambda=_get_charge_state_formatted, + ), + RenaultSensorEntityDescription( + key="charging_remaining_time", + coordinator="battery", + data_key="chargingRemainingTime", + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon="mdi:timer", + name="Charging Remaining Time", + native_unit_of_measurement=TIME_MINUTES, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charging_power", + condition_lambda=lambda a: not a.details.reports_charging_power_in_watts(), + coordinator="battery", + data_key="chargingInstantaneousPower", + device_class=DEVICE_CLASS_CURRENT, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Charging Power", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="charging_power", + condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), + coordinator="battery", + data_key="chargingInstantaneousPower", + device_class=DEVICE_CLASS_POWER, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Charging Power", + native_unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + value_lambda=_get_charging_power, + ), + RenaultSensorEntityDescription( + key="plug_state", + coordinator="battery", + data_key="plugStatus", + device_class=DEVICE_CLASS_PLUG_STATE, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon_lambda=_get_plug_state_icon, + name="Plug State", + value_lambda=_get_plug_state_formatted, + ), + RenaultSensorEntityDescription( + key="battery_autonomy", + coordinator="battery", + data_key="batteryAutonomy", + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + icon="mdi:ev-station", + name="Battery Autonomy", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="battery_available_energy", + coordinator="battery", + data_key="batteryAvailableEnergy", + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + device_class=DEVICE_CLASS_ENERGY, + name="Battery Available Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="battery_temperature", + coordinator="battery", + data_key="batteryTemperature", + device_class=DEVICE_CLASS_TEMPERATURE, + entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], + name="Battery Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + ), + RenaultSensorEntityDescription( + key="mileage", + coordinator="cockpit", + data_key="totalMileage", + entity_class=RenaultSensor[KamereonVehicleCockpitData], + icon="mdi:sign-direction", + name="Mileage", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_TOTAL_INCREASING, + value_lambda=_get_rounded_value, + ), + RenaultSensorEntityDescription( + key="fuel_autonomy", + coordinator="cockpit", + data_key="fuelAutonomy", + entity_class=RenaultSensor[KamereonVehicleCockpitData], + icon="mdi:gas-station", + name="Fuel Autonomy", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_MEASUREMENT, + requires_fuel=True, + value_lambda=_get_rounded_value, + ), + RenaultSensorEntityDescription( + key="fuel_quantity", + coordinator="cockpit", + data_key="fuelQuantity", + entity_class=RenaultSensor[KamereonVehicleCockpitData], + icon="mdi:fuel", + name="Fuel Quantity", + native_unit_of_measurement=VOLUME_LITERS, + state_class=STATE_CLASS_MEASUREMENT, + requires_fuel=True, + value_lambda=_get_rounded_value, + ), + RenaultSensorEntityDescription( + key="outside_temperature", + coordinator="hvac_status", + device_class=DEVICE_CLASS_TEMPERATURE, + data_key="externalTemperature", + entity_class=RenaultSensor[KamereonVehicleHvacStatusData], + name="Outside Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py new file mode 100644 index 00000000000..972befcec6d --- /dev/null +++ b/homeassistant/components/renault/services.py @@ -0,0 +1,165 @@ +"""Support for Renault services.""" +from __future__ import annotations + +from datetime import datetime +import logging +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import DOMAIN +from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy + +LOGGER = logging.getLogger(__name__) + +ATTR_SCHEDULES = "schedules" +ATTR_TEMPERATURE = "temperature" +ATTR_VEHICLE = "vehicle" +ATTR_WHEN = "when" + +SERVICE_VEHICLE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_VEHICLE): cv.string, + } +) +SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_TEMPERATURE): cv.positive_float, + vol.Optional(ATTR_WHEN): cv.datetime, + } +) +SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( + { + vol.Required("startTime"): cv.string, + vol.Required("duration"): cv.positive_int, + } +) +SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required("id"): cv.positive_int, + vol.Optional("activated"): cv.boolean, + vol.Optional("monday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("thursday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + } +) +SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_SCHEDULES): vol.All( + cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA] + ), + } +) + +SERVICE_AC_CANCEL = "ac_cancel" +SERVICE_AC_START = "ac_start" +SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" +SERVICE_CHARGE_START = "charge_start" +SERVICES = [ + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_CHARGE_START, +] + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + + async def ac_cancel(service_call: ServiceCall) -> None: + """Cancel A/C.""" + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("A/C cancel attempt") + result = await proxy.vehicle.set_ac_stop() + LOGGER.debug("A/C cancel result: %s", result) + + async def ac_start(service_call: ServiceCall) -> None: + """Start A/C.""" + temperature: float = service_call.data[ATTR_TEMPERATURE] + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("A/C start attempt: %s / %s", temperature, when) + result = await proxy.vehicle.set_ac_start(temperature, when) + LOGGER.debug("A/C start result: %s", result.raw_data) + + async def charge_set_schedules(service_call: ServiceCall) -> None: + """Set charge schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call.data) + charge_schedules = await proxy.vehicle.get_charging_settings() + for schedule in schedules: + charge_schedules.update(schedule) + + if TYPE_CHECKING: + assert charge_schedules.schedules is not None + LOGGER.debug("Charge set schedules attempt: %s", schedules) + result = await proxy.vehicle.set_charge_schedules(charge_schedules.schedules) + LOGGER.debug("Charge set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + + async def charge_start(service_call: ServiceCall) -> None: + """Start charge.""" + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("Charge start attempt") + result = await proxy.vehicle.set_charge_start() + LOGGER.debug("Charge start result: %s", result) + + def get_vehicle_proxy(service_call_data: MappingProxyType) -> RenaultVehicleProxy: + """Get vehicle from service_call data.""" + device_registry = dr.async_get(hass) + device_id = service_call_data[ATTR_VEHICLE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ValueError(f"Unable to find device with id: {device_id}") + + proxy: RenaultHub + for proxy in hass.data[DOMAIN].values(): + for vin, vehicle in proxy.vehicles.items(): + if (DOMAIN, vin) in device_entry.identifiers: + return vehicle + raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}") + + hass.services.async_register( + DOMAIN, + SERVICE_AC_CANCEL, + ac_cancel, + schema=SERVICE_VEHICLE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_AC_START, + ac_start, + schema=SERVICE_AC_START_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CHARGE_SET_SCHEDULES, + charge_set_schedules, + schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CHARGE_START, + charge_start, + schema=SERVICE_VEHICLE_SCHEMA, + ) + + +def unload_services(hass: HomeAssistant) -> None: + """Unload Renault services.""" + for service in SERVICES: + hass.services.async_remove(DOMAIN, service) diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml new file mode 100644 index 00000000000..7dd2f73ef4b --- /dev/null +++ b/homeassistant/components/renault/services.yaml @@ -0,0 +1,88 @@ +ac_start: + description: Start A/C on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + temperature: + description: Target A/C temperature in °C. + example: "21" + required: true + selector: + number: + min: 15 + max: 25 + step: 0.5 + unit_of_measurement: °C + when: + description: Timestamp for the start of the A/C (optional - defaults to now). + example: "2020-05-01T17:45:00" + selector: + text: + +ac_cancel: + description: Cancel A/C on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + +charge_set_schedules: + description: Update charge schedule on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + schedules: + description: Schedule details. + example: >- + [ + { + 'id':1, + 'activated':true, + 'monday':{'startTime':'T12:00Z','duration':15}, + 'tuesday':{'startTime':'T12:00Z','duration':15}, + 'wednesday':{'startTime':'T12:00Z','duration':15}, + 'thursday':{'startTime':'T12:00Z','duration':15}, + 'friday':{'startTime':'T12:00Z','duration':15}, + 'saturday':{'startTime':'T12:00Z','duration':15}, + 'sunday':{'startTime':'T12:00Z','duration':15} + }, + { + 'id':2, + 'activated':false, + 'monday':{'startTime':'T12:00Z','duration':240}, + 'tuesday':{'startTime':'T12:00Z','duration':240}, + 'wednesday':{'startTime':'T12:00Z','duration':240}, + 'thursday':{'startTime':'T12:00Z','duration':240}, + 'friday':{'startTime':'T12:00Z','duration':240}, + 'saturday':{'startTime':'T12:00Z','duration':240}, + 'sunday':{'startTime':'T12:00Z','duration':240} + }, + ] + required: true + selector: + object: + +charge_start: + description: Start charge on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 942c8b4a06c..30a356b7c42 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "kamereon_no_account": "Unable to find Kamereon account." + "kamereon_no_account": "Unable to find Kamereon account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]" @@ -14,6 +15,13 @@ }, "title": "Select Kamereon account id" }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please update your password for {username}", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/ca.json b/homeassistant/components/renault/translations/ca.json index 8315d35b87b..e16cb333acf 100644 --- a/homeassistant/components/renault/translations/ca.json +++ b/homeassistant/components/renault/translations/ca.json @@ -1,8 +1,9 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", - "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon." + "already_configured": "El compte ja est\u00e0 configurat", + "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_credentials": "Autenticaci\u00f3 inv\u00e0lida" @@ -14,6 +15,13 @@ }, "title": "Seleccioneu l'ID del compte Kamereon" }, + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Actualitza la contrasenya de l'usuari {username}", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "locale": "Llengua/regi\u00f3", diff --git a/homeassistant/components/renault/translations/cs.json b/homeassistant/components/renault/translations/cs.json index d731b4c2ec0..94f2bbd5773 100644 --- a/homeassistant/components/renault/translations/cs.json +++ b/homeassistant/components/renault/translations/cs.json @@ -1,12 +1,19 @@ { "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_credentials": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" }, "step": { + "reauth_confirm": { + "data": { + "password": "Heslo" + }, + "title": "Znovu ov\u011b\u0159it integraci" + }, "user": { "data": { "password": "Heslo", diff --git a/homeassistant/components/renault/translations/de.json b/homeassistant/components/renault/translations/de.json index 16650b8d63e..808d8b174f8 100644 --- a/homeassistant/components/renault/translations/de.json +++ b/homeassistant/components/renault/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Konto wurde bereits konfiguriert", - "kamereon_no_account": "Kamereon-Konto kann nicht gefunden werden." + "kamereon_no_account": "Kamereon-Konto kann nicht gefunden werden.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_credentials": "Ung\u00fcltige Authentifizierung" @@ -14,6 +15,13 @@ }, "title": "Kamereon-Kontonummer ausw\u00e4hlen" }, + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte \u00e4ndere Dein Passwort f\u00fcr {username}", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "locale": "Gebietsschema", diff --git a/homeassistant/components/renault/translations/el.json b/homeassistant/components/renault/translations/el.json new file mode 100644 index 00000000000..4f29e856865 --- /dev/null +++ b/homeassistant/components/renault/translations/el.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "description": "\u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03ae\u03c2 \u03c3\u03b1\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03bf {username}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/en.json b/homeassistant/components/renault/translations/en.json index 87186e6f59c..104a3f0ba64 100644 --- a/homeassistant/components/renault/translations/en.json +++ b/homeassistant/components/renault/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Account is already configured", - "kamereon_no_account": "Unable to find Kamereon account." + "kamereon_no_account": "Unable to find Kamereon account", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_credentials": "Invalid authentication" @@ -14,6 +15,13 @@ }, "title": "Select Kamereon account id" }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please update your password for {username}", + "title": "Reauthenticate Integration" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/es.json b/homeassistant/components/renault/translations/es.json index 0eabcacccd3..cf0f88983e0 100644 --- a/homeassistant/components/renault/translations/es.json +++ b/homeassistant/components/renault/translations/es.json @@ -1,24 +1,32 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada", - "kamereon_no_account": "No se pudo encontrar la cuenta de Kamereon." + "already_configured": "La cuenta ya ha sido configurada", + "kamereon_no_account": "No se pudo encontrar la cuenta de Kamereon.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { - "invalid_credentials": "Autenticaci\u00f3n err\u00f3nea" + "invalid_credentials": "Autenticaci\u00f3n no v\u00e1lida" }, "step": { "kamereon": { "data": { "kamereon_account_id": "ID de cuenta de Kamereon" }, - "title": "Seleccione el id de la cuenta de Kamereon" + "title": "Selecciona el id de la cuenta de Kamereon" + }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Por favor, actualiza tu contrase\u00f1a para {username}", + "title": "Volver a autenticar la integraci\u00f3n" }, "user": { "data": { "locale": "Configuraci\u00f3n regional", - "password": "Clave", - "username": "Correo-e" + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" }, "title": "Establecer las credenciales de Renault" } diff --git a/homeassistant/components/renault/translations/et.json b/homeassistant/components/renault/translations/et.json index bae0db1aed7..464c27d2ecc 100644 --- a/homeassistant/components/renault/translations/et.json +++ b/homeassistant/components/renault/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Konto on juba h\u00e4\u00e4lestatud", - "kamereon_no_account": "Kamereoni kontot ei leitud." + "kamereon_no_account": "Kamereoni kontot ei leitud.", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_credentials": "Tuvastamine nurjus" @@ -14,6 +15,13 @@ }, "title": "Vali Kamereoni konto ID" }, + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Uuenda kasutaja {username} salas\u00f5na.", + "title": "Sidumise taastuvastamine" + }, "user": { "data": { "locale": "Riigi kood (n\u00e4iteks EE)", diff --git a/homeassistant/components/renault/translations/fr.json b/homeassistant/components/renault/translations/fr.json index 874a9b8df67..d0ea9d0284c 100644 --- a/homeassistant/components/renault/translations/fr.json +++ b/homeassistant/components/renault/translations/fr.json @@ -5,7 +5,7 @@ "kamereon_no_account": "Impossible de trouver le compte Kamereon." }, "error": { - "invalid_credentials": "Authentification incorrecte" + "invalid_credentials": "Authentification invalide" }, "step": { "kamereon": { @@ -14,6 +14,9 @@ }, "title": "S\u00e9lectionner l'identifiant du compte Kamereon" }, + "reauth_confirm": { + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "locale": "Lieu", diff --git a/homeassistant/components/renault/translations/he.json b/homeassistant/components/renault/translations/he.json index 25cec1032e9..1518df4599e 100644 --- a/homeassistant/components/renault/translations/he.json +++ b/homeassistant/components/renault/translations/he.json @@ -1,12 +1,20 @@ { "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_credentials": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "description": "\u05e0\u05d0 \u05dc\u05e2\u05d3\u05db\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05e2\u05d1\u05d5\u05e8 {username}", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/renault/translations/hu.json b/homeassistant/components/renault/translations/hu.json index eeace0b9b85..d74d8cdf9e4 100644 --- a/homeassistant/components/renault/translations/hu.json +++ b/homeassistant/components/renault/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "kamereon_no_account": "Nem tal\u00e1lhat\u00f3 a Kamereon-fi\u00f3k." + "kamereon_no_account": "Nem tal\u00e1lhat\u00f3 a Kamereon-fi\u00f3k.", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" }, "error": { "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" @@ -14,6 +15,13 @@ }, "title": "V\u00e1lassza ki a Kamereon-fi\u00f3k azonos\u00edt\u00f3j\u00e1t" }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rj\u00fck, friss\u00edtse {username} jelszav\u00e1t", + "title": "Integr\u00e1ci\u00f3 \u00fajb\u00f3li hiteles\u00edt\u00e9se" + }, "user": { "data": { "locale": "Helysz\u00edn", diff --git a/homeassistant/components/renault/translations/id.json b/homeassistant/components/renault/translations/id.json new file mode 100644 index 00000000000..e1b1f3fc893 --- /dev/null +++ b/homeassistant/components/renault/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "invalid_credentials": "Autentikasi tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Perbarui kata sandi Anda untuk {username}", + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Email" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/it.json b/homeassistant/components/renault/translations/it.json index 37ba94b3cdf..f315a8b5826 100644 --- a/homeassistant/components/renault/translations/it.json +++ b/homeassistant/components/renault/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "kamereon_no_account": "Impossibile trovare l'account Kamereon." + "kamereon_no_account": "Impossibile trovare l'account Kamereon.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_credentials": "Autenticazione non valida" @@ -14,6 +15,13 @@ }, "title": "Seleziona l'id dell'account Kamereon" }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Aggiorna la tua password per {username}", + "title": "Autenticare nuovamente l'integrazione" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/nl.json b/homeassistant/components/renault/translations/nl.json index 4840dd0c07b..c2e02b03166 100644 --- a/homeassistant/components/renault/translations/nl.json +++ b/homeassistant/components/renault/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Account is al geconfigureerd", - "kamereon_no_account": "Kan Kamereon-account niet vinden." + "kamereon_no_account": "Kan Kamereon-account niet vinden.", + "reauth_successful": "Opnieuw verifi\u00ebren is gelukt" }, "error": { "invalid_credentials": "Ongeldige authenticatie" @@ -14,6 +15,13 @@ }, "title": "Selecteer Kamereon-account-ID" }, + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Werk uw wachtwoord voor {gebruikersnaam} bij", + "title": "Integratie opnieuw verifi\u00ebren" + }, "user": { "data": { "locale": "Locale", diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json index 4675f939fdd..9ae887830f2 100644 --- a/homeassistant/components/renault/translations/no.json +++ b/homeassistant/components/renault/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "kamereon_no_account": "Kan ikke finne Kamereon -kontoen." + "kamereon_no_account": "Finner ikke Kamereon-konto", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_credentials": "Ugyldig godkjenning" @@ -14,6 +15,13 @@ }, "title": "Velg Kamereon -konto -ID" }, + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Oppdater passordet ditt for {username}", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "locale": "Lokal", diff --git a/homeassistant/components/renault/translations/ru.json b/homeassistant/components/renault/translations/ru.json index 822d42b6117..65f3a4ffbae 100644 --- a/homeassistant/components/renault/translations/ru.json +++ b/homeassistant/components/renault/translations/ru.json @@ -2,7 +2,8 @@ "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." + "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.", + "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": { "invalid_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." @@ -14,6 +15,13 @@ }, "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 ID \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Kamereon" }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 {username}", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "locale": "\u0420\u0435\u0433\u0438\u043e\u043d", diff --git a/homeassistant/components/renault/translations/te.json b/homeassistant/components/renault/translations/te.json new file mode 100644 index 00000000000..56becef9dee --- /dev/null +++ b/homeassistant/components/renault/translations/te.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u0c2a\u0c41\u0c28\u0c03-\u0c2a\u0c4d\u0c30\u0c3e\u0c2e\u0c3e\u0c23\u0c3f\u0c15\u0c02 \u0c35\u0c3f\u0c1c\u0c2f\u0c35\u0c02\u0c24\u0c2e\u0c2f\u0c3f\u0c02\u0c26\u0c3f" + }, + "step": { + "reauth_confirm": { + "description": "{\u0c2f\u0c42\u0c1c\u0c30\u0c4d \u0c28\u0c47\u0c2e\u0c4d} \u0c15\u0c4a\u0c30\u0c15\u0c41 \u0c26\u0c2f\u0c1a\u0c47\u0c38\u0c3f \u0c2e\u0c40 \u0c2a\u0c3e\u0c38\u0c4d \u0c35\u0c30\u0c4d\u0c21\u0c4d \u0c28\u0c3f \u0c05\u0c2a\u0c4d \u0c21\u0c47\u0c1f\u0c4d \u0c1a\u0c47\u0c2f\u0c02\u0c21\u0c3f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/th.json b/homeassistant/components/renault/translations/th.json new file mode 100644 index 00000000000..e6b4b542eba --- /dev/null +++ b/homeassistant/components/renault/translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u0e01\u0e32\u0e23\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2a\u0e34\u0e17\u0e18\u0e34\u0e4c\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "description": "\u0e42\u0e1b\u0e23\u0e14\u0e2d\u0e31\u0e1b\u0e40\u0e14\u0e15\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a {username}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/zh-Hans.json b/homeassistant/components/renault/translations/tr.json similarity index 54% rename from homeassistant/components/tesla/translations/zh-Hans.json rename to homeassistant/components/renault/translations/tr.json index 35635ce3be3..866fc513d4a 100644 --- a/homeassistant/components/tesla/translations/zh-Hans.json +++ b/homeassistant/components/renault/translations/tr.json @@ -1,9 +1,9 @@ { "config": { "step": { - "user": { + "reauth_confirm": { "data": { - "mfa": "MFA \u4ee3\u7801\uff08\u53ef\u9009\uff09" + "password": "\u015eifre" } } } diff --git a/homeassistant/components/renault/translations/zh-Hans.json b/homeassistant/components/renault/translations/zh-Hans.json index ab8c60ed030..41538c06523 100644 --- a/homeassistant/components/renault/translations/zh-Hans.json +++ b/homeassistant/components/renault/translations/zh-Hans.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", - "kamereon_no_account": "\u65e0\u6cd5\u627e\u5230 Kamereon \u5e10\u6237" + "kamereon_no_account": "\u65e0\u6cd5\u627e\u5230 Kamereon \u5e10\u6237", + "reauth_successful": "\u91cd\u65b0\u9a8c\u8bc1\u6210\u529f" }, "error": { "invalid_credentials": "\u65e0\u6548\u8ba4\u8bc1" @@ -14,6 +15,13 @@ }, "title": "\u9009\u62e9 Kamereon \u8d26\u53f7 ID" }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u8bf7\u66f4\u65b0 {username}\u7684\u5bc6\u7801", + "title": "\u91cd\u65b0\u9a8c\u8bc1" + }, "user": { "data": { "locale": "\u5730\u533a", diff --git a/homeassistant/components/renault/translations/zh-Hant.json b/homeassistant/components/renault/translations/zh-Hant.json index 4ae5413499d..a423d9d2359 100644 --- a/homeassistant/components/renault/translations/zh-Hant.json +++ b/homeassistant/components/renault/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "kamereon_no_account": "\u627e\u4e0d\u5230 Kamereon \u5e33\u865f\u3002" + "kamereon_no_account": "\u627e\u4e0d\u5230 Kamereon \u5e33\u865f", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_credentials": "\u9a57\u8b49\u78bc\u7121\u6548" @@ -14,6 +15,13 @@ }, "title": "\u9078\u64c7 Kamereon \u5e33\u865f ID" }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u66f4\u65b0 {username} \u5bc6\u78bc", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "locale": "\u4f4d\u7f6e", diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 08306396e96..6a47f7bbdf5 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -1,10 +1,14 @@ """Support for Repetier-Server sensors.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging import pyrepetier import voluptuous as vol +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -109,33 +113,66 @@ def has_all_unique_names(value): return value -SENSOR_TYPES = { - # Type, Unit, Icon, post - "bed_temperature": [ - "temperature", - TEMP_CELSIUS, - None, - "_bed_", - DEVICE_CLASS_TEMPERATURE, - ], - "extruder_temperature": [ - "temperature", - TEMP_CELSIUS, - None, - "_extruder_", - DEVICE_CLASS_TEMPERATURE, - ], - "chamber_temperature": [ - "temperature", - TEMP_CELSIUS, - None, - "_chamber_", - DEVICE_CLASS_TEMPERATURE, - ], - "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], +@dataclass +class RepetierRequiredKeysMixin: + """Mixin for required keys.""" + + type: str + + +@dataclass +class RepetierSensorEntityDescription( + SensorEntityDescription, RepetierRequiredKeysMixin +): + """Describes Repetier sensor entity.""" + + +SENSOR_TYPES: dict[str, RepetierSensorEntityDescription] = { + "bed_temperature": RepetierSensorEntityDescription( + key="bed_temperature", + type="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + name="_bed_", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "extruder_temperature": RepetierSensorEntityDescription( + key="extruder_temperature", + type="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + name="_extruder_", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "chamber_temperature": RepetierSensorEntityDescription( + key="chamber_temperature", + type="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + name="_chamber_", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "current_state": RepetierSensorEntityDescription( + key="current_state", + type="state", + icon="mdi:printer-3d", + ), + "current_job": RepetierSensorEntityDescription( + key="current_job", + type="progress", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:file-percent", + name="_current_job", + ), + "job_end": RepetierSensorEntityDescription( + key="job_end", + type="progress", + icon="mdi:clock-end", + name="_job_end", + ), + "job_start": RepetierSensorEntityDescription( + key="job_start", + type="progress", + icon="mdi:clock-start", + name="_job_start", + ), } SENSOR_SCHEMA = vol.Schema( diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 04cff82bcf3..b21ff092c67 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import DEVICE_CLASS_TIMESTAMP from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL +from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL, RepetierSensorEntityDescription _LOGGER = logging.getLogger(__name__) @@ -35,12 +35,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): printer_id = info["printer_id"] sensor_type = info["sensor_type"] temp_id = info["temp_id"] - name = f"{info['name']}{SENSOR_TYPES[sensor_type][3]}" + description = SENSOR_TYPES[sensor_type] + name = f"{info['name']}{description.name or ''}" if temp_id is not None: _LOGGER.debug("%s Temp_id: %s", sensor_type, temp_id) name = f"{name}{temp_id}" sensor_class = sensor_map[sensor_type] - entity = sensor_class(api, temp_id, name, printer_id, sensor_type) + entity = sensor_class(api, temp_id, name, printer_id, description) entities.append(entity) add_entities(entities, True) @@ -49,48 +50,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class RepetierSensor(SensorEntity): """Class to create and populate a Repetier Sensor.""" - def __init__(self, api, temp_id, name, printer_id, sensor_type): - """Init new sensor.""" - self._api = api - self._attributes = {} - self._available = False - self._temp_id = temp_id - self._name = name - self._printer_id = printer_id - self._sensor_type = sensor_type - self._state = None - self._attr_device_class = SENSOR_TYPES[self._sensor_type][4] + entity_description: RepetierSensorEntityDescription + _attr_should_poll = False - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available + def __init__( + self, + api, + temp_id, + name, + printer_id, + description: RepetierSensorEntityDescription, + ): + """Init new sensor.""" + self.entity_description = description + self._api = api + self._attributes: dict = {} + self._temp_id = temp_id + self._printer_id = printer_id + self._state = None + + self._attr_name = name + self._attr_available = False @property def extra_state_attributes(self): """Return sensor attributes.""" return self._attributes - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._sensor_type][1] - - @property - def icon(self): - """Icon to use in the frontend.""" - return SENSOR_TYPES[self._sensor_type][2] - - @property - def should_poll(self): - """Return False as entity is updated from the component.""" - return False - @property def native_value(self): """Return sensor state.""" @@ -109,14 +95,13 @@ class RepetierSensor(SensorEntity): def _get_data(self): """Return new data from the api cache.""" - data = self._api.get_data(self._printer_id, self._sensor_type, self._temp_id) + sensor_type = self.entity_description.key + data = self._api.get_data(self._printer_id, sensor_type, self._temp_id) if data is None: - _LOGGER.debug( - "Data not found for %s and %s", self._sensor_type, self._temp_id - ) - self._available = False + _LOGGER.debug("Data not found for %s and %s", sensor_type, self._temp_id) + self._attr_available = False return None - self._available = True + self._attr_available = True return data def update(self): @@ -125,7 +110,7 @@ class RepetierSensor(SensorEntity): if data is None: return state = data.pop("state") - _LOGGER.debug("Printer %s State %s", self._name, state) + _LOGGER.debug("Printer %s State %s", self.name, state) self._attributes.update(data) self._state = state @@ -147,7 +132,7 @@ class RepetierTempSensor(RepetierSensor): return state = data.pop("state") temp_set = data["temp_set"] - _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self._name, temp_set, state) + _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self.name, temp_set, state) self._attributes.update(data) self._state = state @@ -166,10 +151,7 @@ class RepetierJobSensor(RepetierSensor): class RepetierJobEndSensor(RepetierSensor): """Class to create and populate a Repetier Job End timestamp Sensor.""" - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP + _attr_device_class = DEVICE_CLASS_TIMESTAMP def update(self): """Update the sensor.""" @@ -194,10 +176,7 @@ class RepetierJobEndSensor(RepetierSensor): class RepetierJobStartSensor(RepetierSensor): """Class to create and populate a Repetier Job Start timestamp Sensor.""" - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP + _attr_device_class = DEVICE_CLASS_TIMESTAMP def update(self): """Update the sensor.""" diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 34b7c01600a..970aed38335 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -10,13 +10,9 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( ATTR_DEVICE_ID, - CONF_COMMAND_OFF, - CONF_COMMAND_ON, CONF_DEVICE, - CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DEVICES, CONF_HOST, @@ -33,11 +29,8 @@ from .const import ( COMMAND_GROUP_LIST, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, - CONF_DEBUG, CONF_FIRE_EVENT, - CONF_OFF_DELAY, CONF_REMOVE_DEVICE, - CONF_SIGNAL_REPETITIONS, DATA_CLEANUP_CALLBACKS, DATA_LISTENER, DATA_RFXOBJECT, @@ -65,83 +58,11 @@ def _bytearray_string(data): ) from err -def _ensure_device(value): - if value is None: - return DEVICE_DATA_SCHEMA({}) - return DEVICE_DATA_SCHEMA(value) - - SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) -DEVICE_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_OFF_DELAY): vol.All( - cv.time_period, cv.positive_timedelta, lambda value: value.total_seconds() - ), - vol.Optional(CONF_DATA_BITS): cv.positive_int, - vol.Optional(CONF_COMMAND_ON): cv.byte, - vol.Optional(CONF_COMMAND_OFF): cv.byte, - vol.Optional(CONF_SIGNAL_REPETITIONS, default=1): cv.positive_int, - } -) - -BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEBUG): cv.boolean, - vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): {cv.string: _ensure_device}, - }, -) - -DEVICE_SCHEMA = BASE_SCHEMA.extend({vol.Required(CONF_DEVICE): cv.string}) - -PORT_SCHEMA = BASE_SCHEMA.extend( - {vol.Required(CONF_PORT): cv.port, vol.Optional(CONF_HOST): cv.string} -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.deprecated(CONF_DEBUG), vol.Any(DEVICE_SCHEMA, PORT_SCHEMA))}, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = ["switch", "sensor", "light", "binary_sensor", "cover"] -async def async_setup(hass, config): - """Set up the RFXtrx component.""" - if DOMAIN not in config: - return True - - data = { - CONF_HOST: config[DOMAIN].get(CONF_HOST), - CONF_PORT: config[DOMAIN].get(CONF_PORT), - CONF_DEVICE: config[DOMAIN].get(CONF_DEVICE), - CONF_AUTOMATIC_ADD: config[DOMAIN].get(CONF_AUTOMATIC_ADD), - CONF_DEVICES: config[DOMAIN][CONF_DEVICES], - } - - # Read device_id from the event code add to the data that will end up in the ConfigEntry - for event_code, event_config in data[CONF_DEVICES].items(): - event = get_rfx_object(event_code) - if event is None: - continue - device_id = get_device_id( - event.device, data_bits=event_config.get(CONF_DATA_BITS) - ) - event_config[CONF_DEVICE_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=data, - ) - ) - return True - - async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" hass.data.setdefault(DOMAIN, {}) @@ -272,7 +193,7 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry): @callback def _add_device(event, device_id): """Add a device to config entry.""" - config = DEVICE_DATA_SCHEMA({}) + config = {} config[CONF_DEVICE_ID] = device_id data = entry.data.copy() diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index f6751d760b2..788c5dec436 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -1,7 +1,6 @@ """Support for RFXtrx binary sensors.""" from __future__ import annotations -from dataclasses import replace import logging import RFXtrx as rfxtrxmod @@ -15,7 +14,6 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, - CONF_DEVICE_CLASS, CONF_DEVICES, STATE_ON, ) @@ -23,8 +21,6 @@ from homeassistant.core import callback from homeassistant.helpers import event as evt from . import ( - CONF_DATA_BITS, - CONF_OFF_DELAY, RfxtrxEntity, connect_auto_add, find_possible_pt2262_device, @@ -32,7 +28,13 @@ from . import ( get_pt2262_cmd, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DEVICE_PACKET_TYPE_LIGHTING4 +from .const import ( + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_OFF_DELAY, + DEVICE_PACKET_TYPE_LIGHTING4, +) _LOGGER = logging.getLogger(__name__) @@ -106,12 +108,10 @@ async def async_setup_entry( discovery_info = config_entry.data - def get_sensor_description(type_string: str, device_class: str | None = None): + def get_sensor_description(type_string: str): description = SENSOR_TYPES_DICT.get(type_string) if description is None: description = BinarySensorEntityDescription(key=type_string) - if device_class: - description = replace(description, device_class=device_class) return description for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): @@ -136,9 +136,7 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - get_sensor_description( - event.device.type_string, entity_info.get(CONF_DEVICE_CLASS) - ), + get_sensor_description(event.device.type_string), entity_info.get(CONF_OFF_DELAY), entity_info.get(CONF_DATA_BITS), entity_info.get(CONF_COMMAND_ON), diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 91afd9da999..01bcc6ea035 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -547,30 +547,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config=None): - """Handle the initial step.""" - entry = await self.async_set_unique_id(DOMAIN) - if entry: - if CONF_DEVICES not in entry.data: - # In version 0.113, devices key was not written to config entry. Update the entry with import data - self._abort_if_unique_id_configured(import_config) - else: - self._abort_if_unique_id_configured() - - host = import_config[CONF_HOST] - port = import_config[CONF_PORT] - device = import_config[CONF_DEVICE] - - try: - if host is not None: - await self.async_validate_rfx(host=host, port=port) - else: - await self.async_validate_rfx(device=device) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - - return self.async_create_entry(title="RFXTRX", data=import_config) - async def async_validate_rfx(self, host=None, port=None, device=None): """Create data for rfxtrx entry.""" success = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index d457435f85c..17f54ef24c9 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -4,7 +4,6 @@ CONF_FIRE_EVENT = "fire_event" CONF_DATA_BITS = "data_bits" CONF_AUTOMATIC_ADD = "automatic_add" CONF_SIGNAL_REPETITIONS = "signal_repetitions" -CONF_DEBUG = "debug" CONF_OFF_DELAY = "off_delay" CONF_VENETIAN_BLIND_MODE = "venetian_blind_mode" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index a5f5edd0e42..26a938141a2 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -14,8 +14,6 @@ from homeassistant.const import CONF_DEVICES, STATE_OPEN from homeassistant.core import callback from . import ( - CONF_DATA_BITS, - CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, RfxtrxCommandEntity, connect_auto_add, @@ -25,6 +23,8 @@ from . import ( from .const import ( COMMAND_OFF_LIST, COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_SIGNAL_REPETITIONS, CONF_VENETIAN_BLIND_MODE, CONST_VENETIAN_BLIND_MODE_EU, CONST_VENETIAN_BLIND_MODE_US, diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py new file mode 100644 index 00000000000..37fb39cb499 --- /dev/null +++ b/homeassistant/components/rfxtrx/device_action.py @@ -0,0 +1,99 @@ +"""Provides device automations for RFXCOM RFXtrx.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +import homeassistant.helpers.config_validation as cv + +from . import DATA_RFXOBJECT, DOMAIN +from .helpers import async_get_device_object + +CONF_DATA = "data" +CONF_SUBTYPE = "subtype" + +ACTION_TYPE_COMMAND = "send_command" +ACTION_TYPE_STATUS = "send_status" + +ACTION_TYPES = { + ACTION_TYPE_COMMAND, + ACTION_TYPE_STATUS, +} + +ACTION_SELECTION = { + ACTION_TYPE_COMMAND: "COMMANDS", + ACTION_TYPE_STATUS: "STATUS", +} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_SUBTYPE): str, + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device actions for RFXCOM RFXtrx devices.""" + + try: + device = async_get_device_object(hass, device_id) + except ValueError: + return [] + + actions = [] + for action_type in ACTION_TYPES: + if hasattr(device, action_type): + values = getattr(device, ACTION_SELECTION[action_type], {}) + for value in values.values(): + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: action_type, + CONF_SUBTYPE: value, + } + ) + + return actions + + +def _get_commands(hass, device_id, action_type): + device = async_get_device_object(hass, device_id) + send_fun = getattr(device, action_type) + commands = getattr(device, ACTION_SELECTION[action_type], {}) + return commands, send_fun + + +async def async_validate_action_config(hass, config): + """Validate config.""" + config = ACTION_SCHEMA(config) + commands, _ = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) + sub_type = config[CONF_SUBTYPE] + + if sub_type not in commands.values(): + raise InvalidDeviceAutomationConfig( + f"Subtype {sub_type} not found in device commands {commands}" + ) + + return config + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + rfx = hass.data[DOMAIN][DATA_RFXOBJECT] + commands, send_fun = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) + sub_type = config[CONF_SUBTYPE] + + for key, value in commands.items(): + if value == sub_type: + await hass.async_add_executor_job(send_fun, rfx.transport, key) + return diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py new file mode 100644 index 00000000000..25c8825f6ed --- /dev/null +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -0,0 +1,113 @@ +"""Provides device automations for RFXCOM RFXtrx.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN +from .helpers import async_get_device_object + +CONF_SUBTYPE = "subtype" + +CONF_TYPE_COMMAND = "command" +CONF_TYPE_STATUS = "status" + +TRIGGER_SELECTION = { + CONF_TYPE_COMMAND: "COMMANDS", + CONF_TYPE_STATUS: "STATUS", +} +TRIGGER_TYPES = [ + CONF_TYPE_COMMAND, + CONF_TYPE_STATUS, +] +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + vol.Required(CONF_SUBTYPE): str, + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device triggers for RFXCOM RFXtrx devices.""" + device = async_get_device_object(hass, device_id) + + triggers = [] + for conf_type in TRIGGER_TYPES: + data = getattr(device, TRIGGER_SELECTION[conf_type], {}) + for command in data.values(): + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: conf_type, + CONF_SUBTYPE: command, + } + ) + return triggers + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device = async_get_device_object(hass, config[CONF_DEVICE_ID]) + + action_type = config[CONF_TYPE] + sub_type = config[CONF_SUBTYPE] + commands = getattr(device, TRIGGER_SELECTION[action_type], {}) + if config[CONF_SUBTYPE] not in commands.values(): + raise InvalidDeviceAutomationConfig( + f"Subtype {sub_type} not found in device triggers {commands}" + ) + + return config + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + event_data = {ATTR_DEVICE_ID: config[CONF_DEVICE_ID]} + + if config[CONF_TYPE] == CONF_TYPE_COMMAND: + event_data["values"] = {"Command": config[CONF_SUBTYPE]} + elif config[CONF_TYPE] == CONF_TYPE_STATUS: + event_data["values"] = {"Status": config[CONF_SUBTYPE]} + + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: EVENT_RFXTRX_EVENT, + event_trigger.CONF_EVENT_DATA: event_data, + } + ) + + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py new file mode 100644 index 00000000000..ad7d049fb4c --- /dev/null +++ b/homeassistant/components/rfxtrx/helpers.py @@ -0,0 +1,22 @@ +"""Provides helpers for RFXtrx.""" + + +from RFXtrx import get_device + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType + + +@callback +def async_get_device_object(hass: HomeAssistantType, device_id): + """Get a device for the given device registry id.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if registry_device is None: + raise ValueError(f"Device {device_id} not found") + + device_tuple = list(list(registry_device.identifiers)[0]) + return get_device( + int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3] + ) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index fd790581eda..ea197b5ebc4 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -12,15 +12,18 @@ from homeassistant.const import CONF_DEVICES, STATE_ON from homeassistant.core import callback from . import ( - CONF_DATA_BITS, - CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, RfxtrxCommandEntity, connect_auto_add, get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST +from .const import ( + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_SIGNAL_REPETITIONS, +) _LOGGER = logging.getLogger(__name__) @@ -62,7 +65,7 @@ async def async_setup_entry( device_ids.add(device_id) entity = RfxtrxLight( - event.device, device_id, entity_info[CONF_SIGNAL_REPETITIONS] + event.device, device_id, entity_info.get(CONF_SIGNAL_REPETITIONS, 1) ) entities.append(entity) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index fd3be53bfda..c72d2e288e1 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -1,9 +1,9 @@ """Support for RFXtrx sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable from RFXtrx import ControlEvent, SensorEvent diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index c89fcddb002..75c0de88f13 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -70,5 +70,15 @@ "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "device_automation": { + "action_type": { + "send_status": "Send status update: {subtype}", + "send_command": "Send command: {subtype}" + }, + "trigger_type": { + "status": "Received status: {subtype}", + "command": "Received command: {subtype}" + } } } diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 60ddb9a4d16..2a09d027345 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -8,8 +8,6 @@ from homeassistant.const import CONF_DEVICES, STATE_ON from homeassistant.core import callback from . import ( - CONF_DATA_BITS, - CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, DOMAIN, RfxtrxCommandEntity, @@ -17,7 +15,12 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST +from .const import ( + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + CONF_DATA_BITS, + CONF_SIGNAL_REPETITIONS, +) DATA_SWITCH = f"{DOMAIN}_switch" @@ -61,7 +64,7 @@ async def async_setup_entry( device_ids.add(device_id) entity = RfxtrxSwitch( - event.device, device_id, entity_info[CONF_SIGNAL_REPETITIONS] + event.device, device_id, entity_info.get(CONF_SIGNAL_REPETITIONS, 1) ) entities.append(entity) diff --git a/homeassistant/components/rfxtrx/translations/ca.json b/homeassistant/components/rfxtrx/translations/ca.json index d7db4107e3b..477bfa14608 100644 --- a/homeassistant/components/rfxtrx/translations/ca.json +++ b/homeassistant/components/rfxtrx/translations/ca.json @@ -23,7 +23,7 @@ }, "setup_serial_manual_path": { "data": { - "device": "Ruta del port USB del dispositiu" + "device": "Ruta del dispositiu USB" }, "title": "Ruta" }, @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Envia comanda: {subtype}", + "send_status": "Envia comanda d'estat: {subtype}" + }, + "trigger_type": { + "command": "Comanda rebuda: {subtype}", + "status": "Estat rebut: {subtype}" + } + }, "options": { "error": { "already_configured_device": "El dispositiu ja est\u00e0 configurat", diff --git a/homeassistant/components/rfxtrx/translations/de.json b/homeassistant/components/rfxtrx/translations/de.json index 7b006782d96..ee65e371330 100644 --- a/homeassistant/components/rfxtrx/translations/de.json +++ b/homeassistant/components/rfxtrx/translations/de.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Befehl senden: {subtype}", + "send_status": "Statusaktualisierung senden: {subtype}" + }, + "trigger_type": { + "command": "Empfangener Befehl: {subtype}", + "status": "Erhaltener Status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Ger\u00e4t ist bereits konfiguriert", diff --git a/homeassistant/components/rfxtrx/translations/en.json b/homeassistant/components/rfxtrx/translations/en.json index 5e3f551e0cf..2728c189010 100644 --- a/homeassistant/components/rfxtrx/translations/en.json +++ b/homeassistant/components/rfxtrx/translations/en.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Send command: {subtype}", + "send_status": "Send status update: {subtype}" + }, + "trigger_type": { + "command": "Received command: {subtype}", + "status": "Received status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Device is already configured", diff --git a/homeassistant/components/rfxtrx/translations/es.json b/homeassistant/components/rfxtrx/translations/es.json index c1c4d72735c..fa45fe8a777 100644 --- a/homeassistant/components/rfxtrx/translations/es.json +++ b/homeassistant/components/rfxtrx/translations/es.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Enviar comando: {subtype}", + "send_status": "Enviar actualizaci\u00f3n de estado: {subtype}" + }, + "trigger_type": { + "command": "Comando recibido: {subtype}", + "status": "Estado recibido: {subtype}" + } + }, "options": { "error": { "already_configured_device": "El dispositivo ya est\u00e1 configurado", diff --git a/homeassistant/components/rfxtrx/translations/et.json b/homeassistant/components/rfxtrx/translations/et.json index 662664b4454..1b414db656c 100644 --- a/homeassistant/components/rfxtrx/translations/et.json +++ b/homeassistant/components/rfxtrx/translations/et.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Saada k\u00e4sk: {subtype}", + "send_status": "Saada olekuv\u00e4rskendus: {subtype}" + }, + "trigger_type": { + "command": "Saabunud k\u00e4sk: {subtype}", + "status": "Saabunud olek: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Seade on juba h\u00e4\u00e4lestatud", diff --git a/homeassistant/components/rfxtrx/translations/hu.json b/homeassistant/components/rfxtrx/translations/hu.json index 5b953c1260e..86242a4e973 100644 --- a/homeassistant/components/rfxtrx/translations/hu.json +++ b/homeassistant/components/rfxtrx/translations/hu.json @@ -14,7 +14,7 @@ "other": "\u00dcres", "setup_network": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" @@ -39,6 +39,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Parancs k\u00fcld\u00e9se: {subtype}", + "send_status": "\u00c1llapotfriss\u00edt\u00e9s k\u00fcld\u00e9se: {subtype}" + }, + "trigger_type": { + "command": "Be\u00e9rkezett parancs: {alt\u00edpus}", + "status": "Be\u00e9rkezett st\u00e1tusz: {subtype}" + } + }, "one": "\u00dcres", "options": { "error": { diff --git a/homeassistant/components/rfxtrx/translations/it.json b/homeassistant/components/rfxtrx/translations/it.json index 4d2ae4710e7..d5bb516b26b 100644 --- a/homeassistant/components/rfxtrx/translations/it.json +++ b/homeassistant/components/rfxtrx/translations/it.json @@ -39,6 +39,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Invia comando: {subtype}", + "send_status": "Invia aggiornamento di stato: {subtype}" + }, + "trigger_type": { + "command": "Comando ricevuto: {subtype}", + "status": "Stato ricevuto: {subtype}" + } + }, "one": "Pi\u00f9", "options": { "error": { diff --git a/homeassistant/components/rfxtrx/translations/nl.json b/homeassistant/components/rfxtrx/translations/nl.json index 1d22751ceed..92154861f15 100644 --- a/homeassistant/components/rfxtrx/translations/nl.json +++ b/homeassistant/components/rfxtrx/translations/nl.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Stuur commando: {subtype}", + "send_status": "Stuur status update: {subtype}" + }, + "trigger_type": { + "command": "Ontvangen commando: {subtype}", + "status": "Ontvangen status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Apparaat is al geconfigureerd", diff --git a/homeassistant/components/rfxtrx/translations/no.json b/homeassistant/components/rfxtrx/translations/no.json index 3eb9c9b83df..2f867554442 100644 --- a/homeassistant/components/rfxtrx/translations/no.json +++ b/homeassistant/components/rfxtrx/translations/no.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Send kommando: {subtype}", + "send_status": "Send statusoppdatering: {subtype}" + }, + "trigger_type": { + "command": "Mottatt kommando: {subtype}", + "status": "Mottatt status: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Enheten er allerede konfigurert", diff --git a/homeassistant/components/rfxtrx/translations/ru.json b/homeassistant/components/rfxtrx/translations/ru.json index 5a635766d3f..4a56f37687a 100644 --- a/homeassistant/components/rfxtrx/translations/ru.json +++ b/homeassistant/components/rfxtrx/translations/ru.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u0443: {subtype}", + "send_status": "\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u0430: {subtype}" + }, + "trigger_type": { + "command": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u0430\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u0430: {subtype}", + "status": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u0441\u0442\u0430\u0442\u0443\u0441: {subtype}" + } + }, "options": { "error": { "already_configured_device": "\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.", diff --git a/homeassistant/components/rfxtrx/translations/zh-Hant.json b/homeassistant/components/rfxtrx/translations/zh-Hant.json index fbbfeb5d6a0..ec763ece1de 100644 --- a/homeassistant/components/rfxtrx/translations/zh-Hant.json +++ b/homeassistant/components/rfxtrx/translations/zh-Hant.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "\u50b3\u9001\u547d\u4ee4\uff1a{subtype}", + "send_status": "\u50b3\u9001\u72c0\u614b\u66f4\u65b0\uff1a{subtype}" + }, + "trigger_type": { + "command": "\u63a5\u6536\u547d\u4ee4\uff1a{subtype}", + "status": "\u63a5\u6536\u72c0\u614b\uff1a{subtype}" + } + }, "options": { "error": { "already_configured_device": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", diff --git a/homeassistant/components/ring/translations/fr.json b/homeassistant/components/ring/translations/fr.json index 01bbd6587c3..c86cd78564c 100644 --- a/homeassistant/components/ring/translations/fr.json +++ b/homeassistant/components/ring/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_auth": "Authentification non valide", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/risco/translations/fr.json b/homeassistant/components/risco/translations/fr.json index 69224a3e8b1..0b33b841e1d 100644 --- a/homeassistant/components/risco/translations/fr.json +++ b/homeassistant/components/risco/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "P\u00e9riph\u00e9rique d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de connexion", - "invalid_auth": "Authentification erron\u00e9e", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { "password": "Mot de passe", - "pin": "Code Pin", + "pin": "Code PIN", "username": "Nom d'utilisateur" } } @@ -32,8 +32,8 @@ }, "init": { "data": { - "code_arm_required": "Exiger un code PIN pour armer", - "code_disarm_required": "Exiger un code PIN pour d\u00e9sarmer", + "code_arm_required": "Exiger un Code PIN pour armer", + "code_disarm_required": "Exiger un Code PIN pour d\u00e9sarmer", "scan_interval": "\u00c0 quelle fr\u00e9quence interroger Risco (en secondes)" }, "title": "Configurer les options" diff --git a/homeassistant/components/risco/translations/hu.json b/homeassistant/components/risco/translations/hu.json index aaa7974cd4a..198c30d2b02 100644 --- a/homeassistant/components/risco/translations/hu.json +++ b/homeassistant/components/risco/translations/hu.json @@ -27,8 +27,8 @@ "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" + "description": "V\u00e1lassza ki, hogy milyen \u00e1llapotba \u00e1ll\u00edtsa a Risco riaszt\u00e1st Home Assistant riaszt\u00e1s \u00e9les\u00edt\u00e9sekor", + "title": "Home Assistant \u00e1llapotok megjelen\u00edt\u00e9se Risco \u00e1llapotokba" }, "init": { "data": { diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 26ae393b071..1bcadf9aa88 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -54,10 +54,8 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set the perfume amount.""" - if value.is_integer() and MIN_PERFUME_AMOUNT <= value <= MAX_PERFUME_AMOUNT: - await self._diffuser.set_perfume_amount(int(value)) - else: + if not value.is_integer(): raise ValueError( - f"Can't set the perfume amount to {value}. " - f"Perfume amount must be an integer between {self.min_value} and {self.max_value}, inclusive" + f"Can't set the perfume amount to {value}. Perfume amount must be an integer." ) + await self._diffuser.set_perfume_amount(int(value)) diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index ac6f4aa872a..eac95ee5ed4 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -51,9 +51,4 @@ class DiffuserRoomSize(DiffuserEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the diffuser room size.""" - if option in self.options: - await self._diffuser.set_room_size_square_meter(int(option)) - else: - raise ValueError( - f"Can't set the room size to {option}. Allowed room sizes are: {self.options}" - ) + await self._diffuser.set_room_size_square_meter(int(option)) diff --git a/homeassistant/components/roku/translations/fr.json b/homeassistant/components/roku/translations/fr.json index b3dc08a7dc8..4888ed60a1a 100644 --- a/homeassistant/components/roku/translations/fr.json +++ b/homeassistant/components/roku/translations/fr.json @@ -28,7 +28,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Entrez vos informations Roku." } diff --git a/homeassistant/components/roku/translations/hu.json b/homeassistant/components/roku/translations/hu.json index b7aa12bfb4d..101931e0d21 100644 --- a/homeassistant/components/roku/translations/hu.json +++ b/homeassistant/components/roku/translations/hu.json @@ -2,20 +2,20 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "Roku: {name}", + "flow_title": "{name}", "step": { "discovery_confirm": { "data": { "one": "Egy", "other": "Egy\u00e9b" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a(z) {name}-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?", "title": "Roku" }, "ssdp_confirm": { @@ -23,12 +23,12 @@ "one": "\u00dcres", "other": "\u00dcres" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?", "title": "Roku" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "Adja meg Roku adatait." } diff --git a/homeassistant/components/roku/translations/id.json b/homeassistant/components/roku/translations/id.json index 0e60de9b61f..3a227e80eaf 100644 --- a/homeassistant/components/roku/translations/id.json +++ b/homeassistant/components/roku/translations/id.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Roku: {name}", + "flow_title": "{name}", "step": { "discovery_confirm": { "description": "Ingin menyiapkan {name}?", diff --git a/homeassistant/components/roomba/translations/es.json b/homeassistant/components/roomba/translations/es.json index c78b66bbb87..315c8bda096 100644 --- a/homeassistant/components/roomba/translations/es.json +++ b/homeassistant/components/roomba/translations/es.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "No se ha descubierto ning\u00fan dispositivo Roomba ni Braava en tu red. El BLID es la parte del nombre de host del dispositivo despu\u00e9s de 'iRobot-'. Por favor, sigue los pasos descritos en la documentaci\u00f3n en: {auth_help_url}", + "description": "No se ha descubierto ning\u00fan dispositivo Roomba ni Braava en tu red.", "title": "Conectar manualmente con el dispositivo" }, "user": { diff --git a/homeassistant/components/roomba/translations/fr.json b/homeassistant/components/roomba/translations/fr.json index 767d7a9708a..b4c06b26c9f 100644 --- a/homeassistant/components/roomba/translations/fr.json +++ b/homeassistant/components/roomba/translations/fr.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", - "cannot_connect": "Echec de connection", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", "not_irobot_device": "L'appareil d\u00e9couvert n'est pas un appareil iRobot", "short_blid": "La BLID a \u00e9t\u00e9 tronqu\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer" + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "iRobot {name} ( {host} )", "step": { @@ -42,7 +42,7 @@ "blid": "BLID", "continuous": "En continu", "delay": "D\u00e9lai", - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe" }, "description": "La r\u00e9cup\u00e9ration du BLID et du mot de passe est actuellement un processus manuel. Veuillez suivre les \u00e9tapes d\u00e9crites dans la documentation \u00e0 l'adresse: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", diff --git a/homeassistant/components/roomba/translations/hu.json b/homeassistant/components/roomba/translations/hu.json index 0d76ce920b2..34e36f55150 100644 --- a/homeassistant/components/roomba/translations/hu.json +++ b/homeassistant/components/roomba/translations/hu.json @@ -13,7 +13,7 @@ "step": { "init": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "V\u00e1lasszon egy Roomba vagy Braava k\u00e9sz\u00fcl\u00e9ket.", "title": "Automatikus csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" @@ -32,9 +32,9 @@ "manual": { "data": { "blid": "BLID", - "host": "Hoszt" + "host": "C\u00edm" }, - "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}", + "description": "A h\u00e1l\u00f3zaton egyetlen Roomba vagy Braava sem ker\u00fclt el\u0151.", "title": "Manu\u00e1lis csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" }, "user": { @@ -42,11 +42,11 @@ "blid": "BLID", "continuous": "Folyamatos", "delay": "K\u00e9sleltet\u00e9s", - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3" }, "description": "V\u00e1lasszon Roomba-t vagy Braava-t.", - "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" + "title": "Automatikus csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } }, diff --git a/homeassistant/components/roomba/translations/id.json b/homeassistant/components/roomba/translations/id.json index aaffac267aa..1ade232fd70 100644 --- a/homeassistant/components/roomba/translations/id.json +++ b/homeassistant/components/roomba/translations/id.json @@ -9,7 +9,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "iRobot {name} ({host})", + "flow_title": "{name} ({host})", "step": { "init": { "data": { @@ -19,7 +19,7 @@ "title": "Sambungkan secara otomatis ke perangkat" }, "link": { - "description": "Tekan dan tahan tombol Home pada {name} hingga perangkat mengeluarkan suara (sekitar dua detik).", + "description": "Tekan dan tahan tombol Home pada {name} hingga perangkat mengeluarkan suara (sekitar dua detik), lalu kirim dalam waktu 30 detik.", "title": "Ambil Kata Sandi" }, "link_manual": { @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Tidak ada Roomba atau Braava yang ditemukan di jaringan Anda. BLID adalah bagian dari nama host perangkat setelah `iRobot-`. Ikuti langkah-langkah yang diuraikan dalam dokumentasi di: {auth_help_url}", + "description": "Tidak ada Roomba atau Braava yang ditemukan di jaringan Anda.", "title": "Hubungkan ke perangkat secara manual" }, "user": { @@ -45,8 +45,8 @@ "host": "Host", "password": "Kata Sandi" }, - "description": "Saat ini proses mengambil BLID dan kata sandi merupakan proses manual. Iikuti langkah-langkah yang diuraikan dalam dokumentasi di: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", - "title": "Hubungkan ke perangkat" + "description": "Pilih Roomba atau Braava.", + "title": "Sambungkan secara otomatis ke perangkat" } } }, diff --git a/homeassistant/components/roomba/translations/it.json b/homeassistant/components/roomba/translations/it.json index d5909d5bcc5..4be1be53540 100644 --- a/homeassistant/components/roomba/translations/it.json +++ b/homeassistant/components/roomba/translations/it.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Nessun Roomba o Braava sono stati rilevati all'interno della tua rete. Il BLID \u00e8 la porzione del nome host del dispositivo dopo `iRobot-` o `Roomba-`. Segui i passaggi descritti nella documentazione all'indirizzo: {auth_help_url}", + "description": "Nessun Roomba o Braava \u00e8 stato rilevato sulla rete.", "title": "Connettiti manualmente al dispositivo" }, "user": { diff --git a/homeassistant/components/roon/translations/hu.json b/homeassistant/components/roon/translations/hu.json index 56a8ade165c..7d2b63f0f4b 100644 --- a/homeassistant/components/roon/translations/hu.json +++ b/homeassistant/components/roon/translations/hu.json @@ -9,14 +9,14 @@ }, "step": { "link": { - "description": "Enged\u00e9lyeznie kell az HomeAssistantot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a HomeAssistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", - "title": "Enged\u00e9lyezze a HomeAssistant alkalmaz\u00e1st Roon-ban" + "description": "Enged\u00e9lyeznie kell az Home Assistantot a Roonban. Miut\u00e1n r\u00e1kattintott a K\u00fcld\u00e9s gombra, nyissa meg a Roon Core alkalmaz\u00e1st, nyissa meg a Be\u00e1ll\u00edt\u00e1sokat, \u00e9s enged\u00e9lyezze a Home Assistant funkci\u00f3t a B\u0151v\u00edtm\u00e9nyek lapon.", + "title": "Enged\u00e9lyezze a Home Assistant alkalmaz\u00e1st Roon-ban" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "Nem tal\u00e1lta a Roon szervert, adja meg a gazdag\u00e9p nev\u00e9t vagy IP-c\u00edm\u00e9t." + "description": "A Roon szerver nem tal\u00e1lhat\u00f3, adja meg a hosztnev\u00e9t vagy c\u00edm\u00e9t" } } } diff --git a/homeassistant/components/rpi_power/translations/hu.json b/homeassistant/components/rpi_power/translations/hu.json index feb1687037f..840ce725b8b 100644 --- a/homeassistant/components/rpi_power/translations/hu.json +++ b/homeassistant/components/rpi_power/translations/hu.json @@ -6,9 +6,9 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, - "title": "Raspberry Pi Power Supply Checker" + "title": "Raspberry Pi t\u00e1pegys\u00e9g ellen\u0151rz\u0151" } \ No newline at end of file diff --git a/homeassistant/components/rpi_power/translations/nl.json b/homeassistant/components/rpi_power/translations/nl.json index 5529aa39f20..d9e42ef11f3 100644 --- a/homeassistant/components/rpi_power/translations/nl.json +++ b/homeassistant/components/rpi_power/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 4ea9c27b82e..222b533235d 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -5,7 +5,6 @@ from aiohttp import web import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_OK import homeassistant.helpers.config_validation as cv CONTENT_TYPE_XML = "text/xml" @@ -101,6 +100,4 @@ class RssView(HomeAssistantView): response += "\n" - return web.Response( - body=response, content_type=CONTENT_TYPE_XML, status=HTTP_OK - ) + return web.Response(body=response, content_type=CONTENT_TYPE_XML) diff --git a/homeassistant/components/ruckus_unleashed/translations/hu.json b/homeassistant/components/ruckus_unleashed/translations/hu.json index 0abcc301f0c..9590d3c12be 100644 --- a/homeassistant/components/ruckus_unleashed/translations/hu.json +++ b/homeassistant/components/ruckus_unleashed/translations/hu.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index a420ca53814..f6e15bd074c 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,6 +1,7 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging @@ -8,6 +9,7 @@ from pysabnzbd import SabnzbdApi, SabnzbdApiException import voluptuous as vol from homeassistant.components.discovery import SERVICE_SABNZBD +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -52,19 +54,87 @@ SERVICE_SET_SPEED = "set_speed" SIGNAL_SABNZBD_UPDATED = "sabnzbd_updated" -SENSOR_TYPES = { - "current_status": ["Status", None, "status"], - "speed": ["Speed", DATA_RATE_MEGABYTES_PER_SECOND, "kbpersec"], - "queue_size": ["Queue", DATA_MEGABYTES, "mb"], - "queue_remaining": ["Left", DATA_MEGABYTES, "mbleft"], - "disk_size": ["Disk", DATA_GIGABYTES, "diskspacetotal1"], - "disk_free": ["Disk Free", DATA_GIGABYTES, "diskspace1"], - "queue_count": ["Queue Count", None, "noofslots_total"], - "day_size": ["Daily Total", DATA_GIGABYTES, "day_size"], - "week_size": ["Weekly Total", DATA_GIGABYTES, "week_size"], - "month_size": ["Monthly Total", DATA_GIGABYTES, "month_size"], - "total_size": ["Total", DATA_GIGABYTES, "total_size"], -} + +@dataclass +class SabnzbdRequiredKeysMixin: + """Mixin for required keys.""" + + field_name: str + + +@dataclass +class SabnzbdSensorEntityDescription(SensorEntityDescription, SabnzbdRequiredKeysMixin): + """Describes Sabnzbd sensor entity.""" + + +SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( + SabnzbdSensorEntityDescription( + key="current_status", + name="Status", + field_name="status", + ), + SabnzbdSensorEntityDescription( + key="speed", + name="Speed", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + field_name="kbpersec", + ), + SabnzbdSensorEntityDescription( + key="queue_size", + name="Queue", + native_unit_of_measurement=DATA_MEGABYTES, + field_name="mb", + ), + SabnzbdSensorEntityDescription( + key="queue_remaining", + name="Left", + native_unit_of_measurement=DATA_MEGABYTES, + field_name="mbleft", + ), + SabnzbdSensorEntityDescription( + key="disk_size", + name="Disk", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="diskspacetotal1", + ), + SabnzbdSensorEntityDescription( + key="disk_free", + name="Disk Free", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="diskspace1", + ), + SabnzbdSensorEntityDescription( + key="queue_count", + name="Queue Count", + field_name="noofslots_total", + ), + SabnzbdSensorEntityDescription( + key="day_size", + name="Daily Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="day_size", + ), + SabnzbdSensorEntityDescription( + key="week_size", + name="Weekly Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="week_size", + ), + SabnzbdSensorEntityDescription( + key="month_size", + name="Monthly Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="month_size", + ), + SabnzbdSensorEntityDescription( + key="total_size", + name="Total", + native_unit_of_measurement=DATA_GIGABYTES, + field_name="total_size", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] SPEED_LIMIT_SCHEMA = vol.Schema( {vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string} @@ -80,7 +150,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, } diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index ffe57e608bf..3079bd75601 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -2,7 +2,12 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_SABNZBD, SENSOR_TYPES, SIGNAL_SABNZBD_UPDATED +from . import ( + DATA_SABNZBD, + SENSOR_TYPES, + SIGNAL_SABNZBD_UPDATED, + SabnzbdSensorEntityDescription, +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -14,22 +19,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensors = sab_api_data.sensors client_name = sab_api_data.name async_add_entities( - [SabnzbdSensor(sensor, sab_api_data, client_name) for sensor in sensors] + [ + SabnzbdSensor(sab_api_data, client_name, description) + for description in SENSOR_TYPES + if description.key in sensors + ] ) class SabnzbdSensor(SensorEntity): """Representation of an SABnzbd sensor.""" - def __init__(self, sensor_type, sabnzbd_api_data, client_name): + entity_description: SabnzbdSensorEntityDescription + _attr_should_poll = False + + def __init__( + self, sabnzbd_api_data, client_name, description: SabnzbdSensorEntityDescription + ): """Initialize the sensor.""" - self._client_name = client_name - self._field_name = SENSOR_TYPES[sensor_type][2] - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self._sabnzbd_api = sabnzbd_api_data - self._state = None - self._type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_name = f"{client_name} {description.name}" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" @@ -39,33 +49,15 @@ class SabnzbdSensor(SensorEntity): ) ) - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._client_name} {self._name}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def should_poll(self): - """Don't poll. Will be updated by dispatcher signal.""" - return False - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - def update_state(self, args): """Get the latest data and updates the states.""" - self._state = self._sabnzbd_api.get_queue_field(self._field_name) + self._attr_native_value = self._sabnzbd_api.get_queue_field( + self.entity_description.field_name + ) - if self._type == "speed": - self._state = round(float(self._state) / 1024, 1) - elif "size" in self._type: - self._state = round(float(self._state), 2) + if self.entity_description.key == "speed": + self._attr_native_value = round(float(self._attr_native_value) / 1024, 1) + elif "size" in self.entity_description.key: + self._attr_native_value = round(float(self._attr_native_value), 2) self.schedule_update_ha_state() diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 773c340d7b9..f55dc0639ba 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -1,13 +1,16 @@ """The Samsung TV integration.""" +from __future__ import annotations + from functools import partial import socket +from typing import Any import getmac import voluptuous as vol from homeassistant import config_entries from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -17,10 +20,17 @@ from homeassistant.const import ( CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info +from .bridge import ( + SamsungTVBridge, + SamsungTVLegacyBridge, + SamsungTVWSBridge, + async_get_device_info, + mac_from_device_info, +) from .const import ( CONF_ON_ACTION, DEFAULT_NAME, @@ -32,7 +42,7 @@ from .const import ( ) -def ensure_unique_hosts(value): +def ensure_unique_hosts(value: dict[Any, Any]) -> dict[Any, Any]: """Validate that all configs have a unique host.""" vol.Schema(vol.Unique("duplicate host entries found"))( [entry[CONF_HOST] for entry in value] @@ -64,7 +74,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Samsung TV integration.""" hass.data[DOMAIN] = {} if DOMAIN not in config: @@ -88,7 +98,9 @@ async def async_setup(hass, config): @callback -def _async_get_device_bridge(data): +def _async_get_device_bridge( + data: dict[str, Any] +) -> SamsungTVLegacyBridge | SamsungTVWSBridge: """Get device bridge.""" return SamsungTVBridge.get_bridge( data[CONF_METHOD], @@ -98,13 +110,13 @@ def _async_get_device_bridge(data): ) -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Samsung TV platform.""" # Initialize bridge bridge = await _async_create_bridge_with_updated_data(hass, entry) - def stop_bridge(event): + def stop_bridge(event: Event) -> None: """Stop SamsungTV bridge connection.""" bridge.stop() @@ -117,7 +129,9 @@ async def async_setup_entry(hass, entry): return True -async def _async_create_bridge_with_updated_data(hass, entry): +async def _async_create_bridge_with_updated_data( + hass: HomeAssistant, entry: ConfigEntry +) -> SamsungTVLegacyBridge | SamsungTVWSBridge: """Create a bridge object and update any missing data in the config entry.""" updated_data = {} host = entry.data[CONF_HOST] @@ -163,7 +177,7 @@ async def _async_create_bridge_with_updated_data(hass, entry): return bridge -async def async_unload_entry(hass, entry): +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: @@ -171,7 +185,7 @@ async def async_unload_entry(hass, entry): return unload_ok -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 diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 0d00a0cb94f..262bf4ce67f 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -1,6 +1,9 @@ """samsungctl and samsungtvws bridge classes.""" +from __future__ import annotations + from abc import ABC, abstractmethod import contextlib +from typing import Any from samsungctl import Remote from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse @@ -17,6 +20,7 @@ from homeassistant.const import ( CONF_TIMEOUT, CONF_TOKEN, ) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -37,7 +41,7 @@ from .const import ( ) -def mac_from_device_info(info): +def mac_from_device_info(info: dict[str, Any]) -> str | None: """Extract the mac address from the device info.""" dev_info = info.get("device", {}) if dev_info.get("networkType") == "wireless" and dev_info.get("wifiMac"): @@ -45,12 +49,18 @@ def mac_from_device_info(info): return None -async def async_get_device_info(hass, bridge, host): +async def async_get_device_info( + hass: HomeAssistant, + bridge: SamsungTVWSBridge | SamsungTVLegacyBridge | None, + host: str, +) -> tuple[int | None, str | None, dict[str, Any] | None]: """Fetch the port, method, and device info.""" return await hass.async_add_executor_job(_get_device_info, bridge, host) -def _get_device_info(bridge, host): +def _get_device_info( + bridge: SamsungTVWSBridge | SamsungTVLegacyBridge, host: str +) -> tuple[int | None, str | None, dict[str, Any] | None]: """Fetch the port, method, and device info.""" if bridge and bridge.port: return bridge.port, bridge.method, bridge.device_info() @@ -72,40 +82,42 @@ class SamsungTVBridge(ABC): """The Base Bridge abstract class.""" @staticmethod - def get_bridge(method, host, port=None, token=None): + def get_bridge( + method: str, host: str, port: int | None = None, token: str | None = None + ) -> SamsungTVLegacyBridge | SamsungTVWSBridge: """Get Bridge instance.""" if method == METHOD_LEGACY or port == LEGACY_PORT: return SamsungTVLegacyBridge(method, host, port) return SamsungTVWSBridge(method, host, port, token) - def __init__(self, method, host, port): + def __init__(self, method: str, host: str, port: int | None = None) -> None: """Initialize Bridge.""" self.port = port self.method = method self.host = host - self.token = None - self._remote = None - self._callback = None + self.token: str | None = None + self._remote: Remote | None = None + self._callback: CALLBACK_TYPE | None = None - def register_reauth_callback(self, func): + def register_reauth_callback(self, func: CALLBACK_TYPE) -> None: """Register a callback function.""" self._callback = func @abstractmethod - def try_connect(self): + def try_connect(self) -> str | None: """Try to connect to the TV.""" @abstractmethod - def device_info(self): + def device_info(self) -> dict[str, Any] | None: """Try to gather infos of this TV.""" @abstractmethod - def mac_from_device(self): + def mac_from_device(self) -> str | None: """Try to fetch the mac address of the TV.""" - def is_on(self): + def is_on(self) -> bool: """Tells if the TV is on.""" - if self._remote: + if self._remote is not None: self.close_remote() try: @@ -121,7 +133,7 @@ class SamsungTVBridge(ABC): # Different reasons, e.g. hostname not resolveable return False - def send_key(self, key): + def send_key(self, key: str) -> None: """Send a key to the tv and handles exceptions.""" try: # recreate connection if connection was dead @@ -146,14 +158,14 @@ class SamsungTVBridge(ABC): pass @abstractmethod - def _send_key(self, key): + def _send_key(self, key: str) -> None: """Send the key.""" @abstractmethod - def _get_remote(self, avoid_open: bool = False): + def _get_remote(self, avoid_open: bool = False) -> Remote: """Get Remote object.""" - def close_remote(self): + def close_remote(self) -> None: """Close remote object.""" try: if self._remote is not None: @@ -163,16 +175,16 @@ class SamsungTVBridge(ABC): except OSError: LOGGER.debug("Could not establish connection") - def _notify_callback(self): + def _notify_callback(self) -> None: """Notify access denied callback.""" - if self._callback: + if self._callback is not None: self._callback() class SamsungTVLegacyBridge(SamsungTVBridge): """The Bridge for Legacy TVs.""" - def __init__(self, method, host, port): + def __init__(self, method: str, host: str, port: int | None) -> None: """Initialize Bridge.""" super().__init__(method, host, LEGACY_PORT) self.config = { @@ -185,11 +197,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): CONF_TIMEOUT: 1, } - def mac_from_device(self): + def mac_from_device(self) -> None: """Try to fetch the mac address of the TV.""" return None - def try_connect(self): + def try_connect(self) -> str: """Try to connect to the Legacy TV.""" config = { CONF_NAME: VALUE_CONF_NAME, @@ -216,11 +228,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): LOGGER.debug("Failing config: %s, error: %s", config, err) return RESULT_CANNOT_CONNECT - def device_info(self): + def device_info(self) -> None: """Try to gather infos of this device.""" return None - def _get_remote(self, avoid_open: bool = False): + def _get_remote(self, avoid_open: bool = False) -> Remote: """Create or return a remote control instance.""" if self._remote is None: # We need to create a new instance to reconnect. @@ -238,12 +250,12 @@ class SamsungTVLegacyBridge(SamsungTVBridge): pass return self._remote - def _send_key(self, key): + def _send_key(self, key: str) -> None: """Send the key using legacy protocol.""" if remote := self._get_remote(): remote.control(key) - def stop(self): + def stop(self) -> None: """Stop Bridge.""" LOGGER.debug("Stopping SamsungTVLegacyBridge") self.close_remote() @@ -252,17 +264,19 @@ class SamsungTVLegacyBridge(SamsungTVBridge): class SamsungTVWSBridge(SamsungTVBridge): """The Bridge for WebSocket TVs.""" - def __init__(self, method, host, port, token=None): + def __init__( + self, method: str, host: str, port: int | None = None, token: str | None = None + ) -> None: """Initialize Bridge.""" super().__init__(method, host, port) self.token = token - def mac_from_device(self): + def mac_from_device(self) -> str | None: """Try to fetch the mac address of the TV.""" info = self.device_info() return mac_from_device_info(info) if info else None - def try_connect(self): + def try_connect(self) -> str: """Try to connect to the Websocket TV.""" for self.port in WEBSOCKET_PORTS: config = { @@ -286,7 +300,7 @@ class SamsungTVWSBridge(SamsungTVBridge): ) as remote: remote.open() self.token = remote.token - if self.token: + if self.token is None: config[CONF_TOKEN] = "*****" LOGGER.debug("Working config: %s", config) return RESULT_SUCCESS @@ -304,22 +318,23 @@ class SamsungTVWSBridge(SamsungTVBridge): return RESULT_CANNOT_CONNECT - def device_info(self): + def device_info(self) -> dict[str, Any] | None: """Try to gather infos of this TV.""" - remote = self._get_remote(avoid_open=True) - if not remote: - return None - with contextlib.suppress(HttpApiError): - return remote.rest_device_info() + if remote := self._get_remote(avoid_open=True): + with contextlib.suppress(HttpApiError): + device_info: dict[str, Any] = remote.rest_device_info() + return device_info - def _send_key(self, key): + return None + + def _send_key(self, key: str) -> None: """Send the key using websocket protocol.""" if key == "KEY_POWEROFF": key = "KEY_POWER" if remote := self._get_remote(): remote.send_key(key) - def _get_remote(self, avoid_open: bool = False): + def _get_remote(self, avoid_open: bool = False) -> Remote: """Create or return a remote control instance.""" if self._remote is None: # We need to create a new instance to reconnect. @@ -344,7 +359,7 @@ class SamsungTVWSBridge(SamsungTVBridge): self._remote = None return self._remote - def stop(self): + def stop(self) -> None: """Stop Bridge.""" LOGGER.debug("Stopping SamsungTVWSBridge") self.close_remote() diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index da13d0fe70c..bcce5eec5ed 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -1,5 +1,9 @@ """Config flow for Samsung TV.""" +from __future__ import annotations + import socket +from types import MappingProxyType +from typing import Any from urllib.parse import urlparse import getmac @@ -25,7 +29,13 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.typing import DiscoveryInfoType -from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info +from .bridge import ( + SamsungTVBridge, + SamsungTVLegacyBridge, + SamsungTVWSBridge, + async_get_device_info, + mac_from_device_info, +) from .const import ( ATTR_PROPERTIES, CONF_MANUFACTURER, @@ -48,11 +58,11 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): SUPPORTED_METHODS = [METHOD_LEGACY, METHOD_WEBSOCKET] -def _strip_uuid(udn): +def _strip_uuid(udn: str) -> str: return udn[5:] if udn.startswith("uuid:") else udn -def _entry_is_complete(entry): +def _entry_is_complete(entry: config_entries.ConfigEntry) -> bool: """Return True if the config entry information is complete.""" return bool(entry.unique_id and entry.data.get(CONF_MAC)) @@ -62,22 +72,24 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" - self._reauth_entry = None - self._host = None - self._mac = None - self._udn = None - self._manufacturer = None - self._model = None - self._name = None - self._title = None - self._id = None - self._bridge = None - self._device_info = None + self._reauth_entry: config_entries.ConfigEntry | None = None + self._host: str = "" + self._mac: str | None = None + self._udn: str | None = None + self._manufacturer: str | None = None + self._model: str | None = None + self._name: str | None = None + self._title: str = "" + self._id: int | None = None + self._bridge: SamsungTVLegacyBridge | SamsungTVWSBridge | None = None + self._device_info: dict[str, Any] | None = None - def _get_entry_from_bridge(self): + def _get_entry_from_bridge(self) -> data_entry_flow.FlowResult: """Get device entry.""" + assert self._bridge + data = { CONF_HOST: self._host, CONF_MAC: self._mac, @@ -94,14 +106,16 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data=data, ) - async def _async_set_device_unique_id(self, raise_on_progress=True): + async def _async_set_device_unique_id(self, raise_on_progress: bool = True) -> None: """Set device unique_id.""" if not await self._async_get_and_check_device_info(): raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) await self._async_set_unique_id_from_udn(raise_on_progress) self._async_update_and_abort_for_matching_unique_id() - async def _async_set_unique_id_from_udn(self, raise_on_progress=True): + async def _async_set_unique_id_from_udn( + self, raise_on_progress: bool = True + ) -> None: """Set the unique id from the udn.""" assert self._host is not None await self.async_set_unique_id(self._udn, raise_on_progress=raise_on_progress) @@ -110,14 +124,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): raise data_entry_flow.AbortFlow("already_configured") - def _async_update_and_abort_for_matching_unique_id(self): + def _async_update_and_abort_for_matching_unique_id(self) -> None: """Abort and update host and mac if we have it.""" updates = {CONF_HOST: self._host} if self._mac: updates[CONF_MAC] = self._mac self._abort_if_unique_id_configured(updates=updates) - def _try_connect(self): + def _try_connect(self) -> None: """Try to connect and check auth.""" for method in SUPPORTED_METHODS: self._bridge = SamsungTVBridge.get_bridge(method, self._host) @@ -129,7 +143,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("No working config found") raise data_entry_flow.AbortFlow(RESULT_CANNOT_CONNECT) - async def _async_get_and_check_device_info(self): + async def _async_get_and_check_device_info(self) -> bool: """Try to get the device info.""" _port, _method, info = await async_get_device_info( self.hass, self._bridge, self._host @@ -160,7 +174,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._device_info = info return True - async def async_step_import(self, user_input=None): + async def async_step_import( + self, user_input: dict[str, Any] + ) -> data_entry_flow.FlowResult: """Handle configuration by yaml file.""" # We need to import even if we cannot validate # since the TV may be off at startup @@ -177,21 +193,24 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data=user_input, ) - async def _async_set_name_host_from_input(self, user_input): + async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: try: self._host = await self.hass.async_add_executor_job( socket.gethostbyname, user_input[CONF_HOST] ) except socket.gaierror as err: raise data_entry_flow.AbortFlow(RESULT_UNKNOWN_HOST) from err - self._name = user_input.get(CONF_NAME, self._host) + self._name = user_input.get(CONF_NAME, self._host) or "" self._title = self._name - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: await self._async_set_name_host_from_input(user_input) await self.hass.async_add_executor_job(self._try_connect) + assert self._bridge self._async_abort_entries_match({CONF_HOST: self._host}) if self._bridge.method != METHOD_LEGACY: # Legacy bridge does not provide device info @@ -201,7 +220,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) @callback - def _async_update_existing_host_entry(self): + def _async_update_existing_host_entry(self) -> config_entries.ConfigEntry | None: """Check existing entries and update them. Returns the existing entry if it was updated. @@ -209,7 +228,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries(include_ignore=False): if entry.data[CONF_HOST] != self._host: continue - entry_kw_args = {} + entry_kw_args: dict = {} if self.unique_id and entry.unique_id is None: entry_kw_args["unique_id"] = self.unique_id if self._mac and not entry.data.get(CONF_MAC): @@ -222,7 +241,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return entry return None - async def _async_start_discovery_with_mac_address(self): + async def _async_start_discovery_with_mac_address(self) -> None: """Start discovery.""" assert self._host is not None if (entry := self._async_update_existing_host_entry()) and entry.unique_id: @@ -232,25 +251,28 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_if_host_already_in_progress() @callback - def _async_abort_if_host_already_in_progress(self): + def _async_abort_if_host_already_in_progress(self) -> None: self.context[CONF_HOST] = self._host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self._host: raise data_entry_flow.AbortFlow("already_in_progress") @callback - def _abort_if_manufacturer_is_not_samsung(self): + def _abort_if_manufacturer_is_not_samsung(self) -> None: if not self._manufacturer or not self._manufacturer.lower().startswith( "samsung" ): raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType): + async def async_step_ssdp( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name = discovery_info.get(ATTR_UPNP_MODEL_NAME) + model_name: str = discovery_info.get(ATTR_UPNP_MODEL_NAME) or "" self._udn = _strip_uuid(discovery_info[ATTR_UPNP_UDN]) - self._host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + if hostname := urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname: + self._host = hostname await self._async_set_unique_id_from_udn() self._manufacturer = discovery_info[ATTR_UPNP_MANUFACTURER] self._abort_if_manufacturer_is_not_samsung() @@ -263,7 +285,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_dhcp(self, discovery_info: DiscoveryInfoType): + async def async_step_dhcp( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) self._mac = discovery_info[MAC_ADDRESS] @@ -273,7 +297,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) self._mac = format_mac(discovery_info[ATTR_PROPERTIES]["deviceid"]) @@ -283,11 +309,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: await self.hass.async_add_executor_job(self._try_connect) + assert self._bridge return self._get_entry_from_bridge() self._set_confirm_only() @@ -295,11 +324,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"device": self._title} ) - async def async_step_reauth(self, data): + async def async_step_reauth( + self, data: MappingProxyType[str, Any] + ) -> data_entry_flow.FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + assert self._reauth_entry data = self._reauth_entry.data if data.get(CONF_MODEL) and data.get(CONF_NAME): self._title = f"{data[CONF_NAME]} ({data[CONF_MODEL]})" @@ -307,9 +339,12 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._title = data.get(CONF_NAME) or data[CONF_HOST] 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 + ) -> data_entry_flow.FlowResult: """Confirm reauth.""" errors = {} + assert self._reauth_entry if user_input is not None: bridge = SamsungTVBridge.get_bridge( self._reauth_entry.data[CONF_METHOD], self._reauth_entry.data[CONF_HOST] diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 7efdcdcd439..cab6435af95 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -1,6 +1,9 @@ """Support for interface with an Samsung TV.""" +from __future__ import annotations + import asyncio -from datetime import timedelta +from datetime import datetime, timedelta +from typing import Any import voluptuous as vol from wakeonlan import send_magic_packet @@ -19,11 +22,18 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.components.samsungtv.bridge import ( + SamsungTVLegacyBridge, + SamsungTVWSBridge, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.util import dt as dt_util @@ -59,7 +69,9 @@ SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta ) -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 Samsung TV from a config entry.""" bridge = hass.data[DOMAIN][entry.entry_id] @@ -77,33 +89,38 @@ async def async_setup_entry(hass, entry, async_add_entities): class SamsungTVDevice(MediaPlayerEntity): """Representation of a Samsung TV.""" - def __init__(self, bridge, config_entry, on_script): + def __init__( + self, + bridge: SamsungTVLegacyBridge | SamsungTVWSBridge, + config_entry: ConfigEntry, + on_script: Script | None, + ) -> None: """Initialize the Samsung device.""" self._config_entry = config_entry - self._host = config_entry.data[CONF_HOST] - self._mac = config_entry.data.get(CONF_MAC) - self._manufacturer = config_entry.data.get(CONF_MANUFACTURER) - self._model = config_entry.data.get(CONF_MODEL) - self._name = config_entry.data.get(CONF_NAME) + self._host: str | None = config_entry.data[CONF_HOST] + self._mac: str | None = config_entry.data.get(CONF_MAC) + self._manufacturer: str | None = config_entry.data.get(CONF_MANUFACTURER) + self._model: str | None = config_entry.data.get(CONF_MODEL) + self._name: str | None = config_entry.data.get(CONF_NAME) self._on_script = on_script self._uuid = config_entry.unique_id # Assume that the TV is not muted - self._muted = False + self._muted: bool = False # Assume that the TV is in Play mode - self._playing = True - self._state = None + self._playing: bool = True + self._state: str | None = None # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). - self._end_of_power_off = None + self._end_of_power_off: datetime | None = None self._bridge = bridge self._auth_failed = False self._bridge.register_reauth_callback(self.access_denied) - def access_denied(self): + def access_denied(self) -> None: """Access denied callback.""" LOGGER.debug("Access denied in getting remote object") self._auth_failed = True - self.hass.add_job( + self.hass.create_task( self.hass.config_entries.flow.async_init( DOMAIN, context={ @@ -114,91 +131,92 @@ class SamsungTVDevice(MediaPlayerEntity): ) ) - def update(self): + def update(self) -> None: """Update state of device.""" - if self._auth_failed: + if self._auth_failed or self.hass.is_stopping: return if self._power_off_in_progress(): self._state = STATE_OFF else: self._state = STATE_ON if self._bridge.is_on() else STATE_OFF - def send_key(self, key): + def send_key(self, key: str) -> None: """Send a key to the tv and handles exceptions.""" if self._power_off_in_progress() and key != "KEY_POWEROFF": LOGGER.info("TV is powering off, not sending command: %s", key) return self._bridge.send_key(key) - def _power_off_in_progress(self): + def _power_off_in_progress(self) -> bool: return ( self._end_of_power_off is not None and self._end_of_power_off > dt_util.utcnow() ) @property - def unique_id(self) -> str: + def unique_id(self) -> str | None: """Return the unique ID of the device.""" return self._uuid @property - def name(self): + def name(self) -> str | None: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @property - def available(self): + def available(self) -> bool: """Return the availability of the device.""" if self._auth_failed: return False return ( self._state == STATE_ON - or self._on_script - or self._mac + or self._on_script is not None + or self._mac is not None or self._power_off_in_progress() ) @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" - info = { + info: DeviceInfo = { "name": self.name, - "identifiers": {(DOMAIN, self.unique_id)}, "manufacturer": self._manufacturer, "model": self._model, } + if self.unique_id: + info["identifiers"] = {(DOMAIN, self.unique_id)} if self._mac: info["connections"] = {(CONNECTION_NETWORK_MAC, self._mac)} return info @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" return self._muted @property - def source_list(self): + def source_list(self) -> list: """List of available input sources.""" return list(SOURCES) @property - def supported_features(self): + def supported_features(self) -> int: """Flag media player features that are supported.""" if self._on_script or self._mac: return SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON return SUPPORT_SAMSUNGTV @property - def device_class(self): + def device_class(self) -> str: """Set the device class to TV.""" return DEVICE_CLASS_TV - def turn_off(self): + def turn_off(self) -> None: """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME @@ -206,44 +224,46 @@ class SamsungTVDevice(MediaPlayerEntity): # Force closing of remote session to provide instant UI feedback self._bridge.close_remote() - def volume_up(self): + def volume_up(self) -> None: """Volume up the media player.""" self.send_key("KEY_VOLUP") - def volume_down(self): + def volume_down(self) -> None: """Volume down media player.""" self.send_key("KEY_VOLDOWN") - def mute_volume(self, mute): + def mute_volume(self, mute: bool) -> None: """Send mute command.""" self.send_key("KEY_MUTE") - def media_play_pause(self): + def media_play_pause(self) -> None: """Simulate play pause media player.""" if self._playing: self.media_pause() else: self.media_play() - def media_play(self): + def media_play(self) -> None: """Send play command.""" self._playing = True self.send_key("KEY_PLAY") - def media_pause(self): + def media_pause(self) -> None: """Send media pause command to media player.""" self._playing = False self.send_key("KEY_PAUSE") - def media_next_track(self): + def media_next_track(self) -> None: """Send next track command.""" self.send_key("KEY_CHUP") - def media_previous_track(self): + def media_previous_track(self) -> None: """Send the previous track command.""" self.send_key("KEY_CHDOWN") - async def async_play_media(self, media_type, media_id, **kwargs): + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Support changing a channel.""" if media_type != MEDIA_TYPE_CHANNEL: LOGGER.error("Unsupported media type") @@ -261,21 +281,21 @@ class SamsungTVDevice(MediaPlayerEntity): await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop) await self.hass.async_add_executor_job(self.send_key, "KEY_ENTER") - def _wake_on_lan(self): + def _wake_on_lan(self) -> None: """Wake the device via wake on lan.""" send_magic_packet(self._mac, ip_address=self._host) # If the ip address changed since we last saw the device # broadcast a packet as well send_magic_packet(self._mac) - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn the media player on.""" if self._on_script: await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) - def select_source(self, source): + def select_source(self, source: str) -> None: """Select input source.""" if source not in SOURCES: LOGGER.error("Unsupported source") diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f92990e6163..f413a7f1219 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -14,7 +14,7 @@ }, "reauth_confirm": { "description": "After submitting, accept the the popup on {device} requesting authorization within 30 seconds." - } + } }, "error": { "auth_missing": "[%key:component::samsungtv::config::abort::auth_missing%]" diff --git a/homeassistant/components/samsungtv/translations/bg.json b/homeassistant/components/samsungtv/translations/bg.json new file mode 100644 index 00000000000..c30e629d8ad --- /dev/null +++ b/homeassistant/components/samsungtv/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/ca.json b/homeassistant/components/samsungtv/translations/ca.json index 64e2298d141..e701bdb1d92 100644 --- a/homeassistant/components/samsungtv/translations/ca.json +++ b/homeassistant/components/samsungtv/translations/ca.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant no est\u00e0 autenticat per connectar-se amb aquest televisor Samsung. V\u00e9s a la configuraci\u00f3 de dispositius externs del televisor per autoritzar Home Assistant.", "cannot_connect": "Ha fallat la connexi\u00f3", "id_missing": "El dispositiu Samsung no t\u00e9 cap n\u00famero de s\u00e8rie.", + "missing_config_entry": "Aquest dispositiu Samsung no t\u00e9 cap entrada de configuraci\u00f3.", "not_supported": "Actualment aquest dispositiu Samsung no \u00e9s compatible.", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" diff --git a/homeassistant/components/samsungtv/translations/de.json b/homeassistant/components/samsungtv/translations/de.json index f59004a5dab..ec5b791626a 100644 --- a/homeassistant/components/samsungtv/translations/de.json +++ b/homeassistant/components/samsungtv/translations/de.json @@ -6,6 +6,7 @@ "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", "id_missing": "Dieses Samsung-Ger\u00e4t hat keine Seriennummer.", + "missing_config_entry": "Dieses Samsung-Ger\u00e4t hat keinen Konfigurationseintrag.", "not_supported": "Dieses Samsung TV-Ger\u00e4t wird derzeit nicht unterst\u00fctzt.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" diff --git a/homeassistant/components/samsungtv/translations/en.json b/homeassistant/components/samsungtv/translations/en.json index 91576e76ee5..4648f930e9b 100644 --- a/homeassistant/components/samsungtv/translations/en.json +++ b/homeassistant/components/samsungtv/translations/en.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", "cannot_connect": "Failed to connect", "id_missing": "This Samsung device doesn't have a SerialNumber.", + "missing_config_entry": "This Samsung device doesn't have a configuration entry.", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" diff --git a/homeassistant/components/samsungtv/translations/es.json b/homeassistant/components/samsungtv/translations/es.json index 0228ca3101f..42b0f794e7a 100644 --- a/homeassistant/components/samsungtv/translations/es.json +++ b/homeassistant/components/samsungtv/translations/es.json @@ -6,12 +6,18 @@ "auth_missing": "Home Assistant no est\u00e1 autorizado para conectarse a este televisor Samsung. Revisa la configuraci\u00f3n de tu televisor para autorizar a Home Assistant.", "cannot_connect": "No se pudo conectar", "id_missing": "Este dispositivo Samsung no tiene un n\u00famero de serie.", - "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible." + "missing_config_entry": "Este dispositivo de Samsung no est\u00e1 configurado.", + "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "unknown": "Error inesperado" }, - "flow_title": "Televisor Samsung: {model}", + "error": { + "auth_missing": "Home Assistant no est\u00e1 autorizado para conectarse a este televisor Samsung. Revisa la configuraci\u00f3n de tu televisor para autorizar a Home Assistant." + }, + "flow_title": "{device}", "step": { "confirm": { - "description": "\u00bfQuieres configurar la televisi\u00f3n Samsung {model}? Si nunca la has conectado a Home Assistant antes deber\u00edas ver una ventana en tu TV pidiendo autorizaci\u00f3n. Cualquier configuraci\u00f3n manual de esta TV se sobreescribir\u00e1.", + "description": "\u00bfQuieres configurar {device}? Si nunca la has conectado a Home Assistant antes deber\u00edas ver una ventana en tu TV pidiendo autorizaci\u00f3n.", "title": "Samsung TV" }, "reauth_confirm": { diff --git a/homeassistant/components/samsungtv/translations/et.json b/homeassistant/components/samsungtv/translations/et.json index 0cc9bf8ebcc..47360f4ed06 100644 --- a/homeassistant/components/samsungtv/translations/et.json +++ b/homeassistant/components/samsungtv/translations/et.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistantil pole selle Samsungi teleriga \u00fchenduse loomiseks luba. Home Assistanti autoriseerimiseks kontrolli oma teleri seadeid.", "cannot_connect": "\u00dchendamine nurjus", "id_missing": "Sellel Samsungi seadmel puudub seerianumber.", + "missing_config_entry": "Sellel Samsungi seadmel puudub seadekirje.", "not_supported": "Seda Samsungi seadet praegu ei toetata.", "reauth_successful": "Taastuvastamine \u00f5nnestus", "unknown": "Tundmatu t\u00f5rge" diff --git a/homeassistant/components/samsungtv/translations/fr.json b/homeassistant/components/samsungtv/translations/fr.json index 5a20992d8e5..9ee94b5edc2 100644 --- a/homeassistant/components/samsungtv/translations/fr.json +++ b/homeassistant/components/samsungtv/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "Ce t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "La configuration du t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 en cours.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration 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", "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", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "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." + "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." }, "flow_title": "Samsung TV: {model}", "step": { @@ -24,7 +24,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "name": "Nom" }, "description": "Entrez les informations relatives \u00e0 votre t\u00e9l\u00e9viseur 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." diff --git a/homeassistant/components/samsungtv/translations/hu.json b/homeassistant/components/samsungtv/translations/hu.json index f0aa85433a1..efdf5f4810b 100644 --- a/homeassistant/components/samsungtv/translations/hu.json +++ b/homeassistant/components/samsungtv/translations/hu.json @@ -2,21 +2,22 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "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.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "auth_missing": "Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizze a TV be\u00e1ll\u00edt\u00e1sait Home Assistant enged\u00e9lyez\u00e9s\u00e9hez.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "id_missing": "Ennek a Samsung eszk\u00f6znek nincs sorsz\u00e1ma.", + "missing_config_entry": "Ez a Samsung eszk\u00f6z nem rendelkezik konfigur\u00e1ci\u00f3s bejegyz\u00e9ssel.", "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" }, "error": { - "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." + "auth_missing": "Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizze a TV be\u00e1ll\u00edt\u00e1sait Home Assistant enged\u00e9lyez\u00e9s\u00e9hez." }, "flow_title": "{device}", "step": { "confirm": { - "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.", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani {device} k\u00e9sz\u00fcl\u00e9k\u00e9t? Ha kor\u00e1bban m\u00e9g sosem csatlakoztatta Home Assistanthoz, 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": { @@ -24,10 +25,10 @@ }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, - "description": "\u00cdrd be a Samsung TV adatait. Ha m\u00e9g soha nem csatlakozott Home Assistant-hez, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ahol hiteles\u00edt\u00e9st k\u00e9r." + "description": "\u00cdrja be a Samsung TV adatait. Ha m\u00e9g soha nem csatlakozott Home Assistanthoz, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ahol meg kell adni az enged\u00e9lyt." } } } diff --git a/homeassistant/components/samsungtv/translations/id.json b/homeassistant/components/samsungtv/translations/id.json index 0b8bbe60150..0714af37146 100644 --- a/homeassistant/components/samsungtv/translations/id.json +++ b/homeassistant/components/samsungtv/translations/id.json @@ -3,21 +3,26 @@ "abort": { "already_configured": "Perangkat sudah dikonfigurasi", "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.", + "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa Manajer Perangkat Eksternal TV Anda untuk mengotorisasi Home Assistant.", "cannot_connect": "Gagal terhubung", + "id_missing": "Perangkat Samsung ini tidak memiliki SerialNumber.", + "missing_config_entry": "Perangkat Samsung ini tidak memiliki entri konfigurasi.", "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." + "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa Manajer Perangkat Eksternal TV Anda untuk mengotorisasi Home Assistant." }, - "flow_title": "TV Samsung: {model}", + "flow_title": "{device}", "step": { "confirm": { - "description": "Apakah Anda ingin menyiapkan TV Samsung {model}? Jika Anda belum pernah menyambungkan Home Assistant sebelumnya, Anda akan melihat dialog di TV yang meminta otorisasi. Konfigurasi manual untuk TV ini akan ditimpa.", + "description": "Apakah Anda ingin menyiapkan {device}? Jika Anda belum pernah menyambungkan Home Assistant sebelumnya, Anda akan melihat dialog di TV yang meminta otorisasi.", "title": "TV Samsung" }, + "reauth_confirm": { + "description": "Setelah mengirimkan, setujui pada popup di {device} yang meminta otorisasi dalam waktu 30 detik." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/samsungtv/translations/it.json b/homeassistant/components/samsungtv/translations/it.json index ee1219305d7..51f9b4e2ef9 100644 --- a/homeassistant/components/samsungtv/translations/it.json +++ b/homeassistant/components/samsungtv/translations/it.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant non \u00e8 autorizzato a connettersi a questo televisore Samsung. Controlla le impostazioni di Gestione dispositivi esterni della tua TV per autorizzare Home Assistant.", "cannot_connect": "Impossibile connettersi", "id_missing": "Questo dispositivo Samsung non ha un SerialNumber.", + "missing_config_entry": "Questo dispositivo Samsung non ha una voce di configurazione.", "not_supported": "Questo dispositivo Samsung non \u00e8 attualmente supportato.", "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "unknown": "Errore imprevisto" diff --git a/homeassistant/components/samsungtv/translations/nl.json b/homeassistant/components/samsungtv/translations/nl.json index b4478994e1c..692c70642d6 100644 --- a/homeassistant/components/samsungtv/translations/nl.json +++ b/homeassistant/components/samsungtv/translations/nl.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant is niet gemachtigd om verbinding te maken met deze Samsung TV. Controleer de instellingen van Extern apparaatbeheer van uw tv om Home Assistant te machtigen.", "cannot_connect": "Kan geen verbinding maken", "id_missing": "Dit Samsung-apparaat heeft geen serienummer.", + "missing_config_entry": "Dit Samsung-apparaat heeft geen configuratie-invoer.", "not_supported": "Deze Samsung TV wordt momenteel niet ondersteund.", "reauth_successful": "Herauthenticatie was succesvol", "unknown": "Onverwachte fout" diff --git a/homeassistant/components/samsungtv/translations/no.json b/homeassistant/components/samsungtv/translations/no.json index 6da9787d3f6..7b7108cbf77 100644 --- a/homeassistant/components/samsungtv/translations/no.json +++ b/homeassistant/components/samsungtv/translations/no.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant er ikke autorisert til \u00e5 koble til denne Samsung TV-en. Sjekk TV-ens innstillinger for ekstern enhetsbehandling for \u00e5 autorisere Home Assistant.", "cannot_connect": "Tilkobling mislyktes", "id_missing": "Denne Samsung-enheten har ikke serienummer.", + "missing_config_entry": "Denne Samsung -enheten har ingen konfigurasjonsoppf\u00f8ring.", "not_supported": "Denne Samsung-enheten st\u00f8ttes forel\u00f8pig ikke.", "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", "unknown": "Uventet feil" diff --git a/homeassistant/components/samsungtv/translations/ru.json b/homeassistant/components/samsungtv/translations/ru.json index 7d4c24aba45..111b30c5488 100644 --- a/homeassistant/components/samsungtv/translations/ru.json +++ b/homeassistant/components/samsungtv/translations/ru.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant \u043d\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u044d\u0442\u043e\u043c\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Samsung TV. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 External Device Manager \u0412\u0430\u0448\u0435\u0433\u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "id_missing": "\u0423 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Samsung \u043d\u0435\u0442 \u0441\u0435\u0440\u0438\u0439\u043d\u043e\u0433\u043e \u043d\u043e\u043c\u0435\u0440\u0430.", + "missing_config_entry": "\u041e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Samsung.", "not_supported": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Samsung \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", "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.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." diff --git a/homeassistant/components/samsungtv/translations/zh-Hant.json b/homeassistant/components/samsungtv/translations/zh-Hant.json index 950a460965b..ba828665cea 100644 --- a/homeassistant/components/samsungtv/translations/zh-Hant.json +++ b/homeassistant/components/samsungtv/translations/zh-Hant.json @@ -6,6 +6,7 @@ "auth_missing": "Home Assistant \u672a\u7372\u5f97\u9a57\u8b49\u4ee5\u9023\u7dda\u81f3\u6b64\u4e09\u661f\u96fb\u8996\u3002\u8acb\u6aa2\u67e5\u60a8\u7684\u96fb\u8996\u5916\u90e8\u88dd\u7f6e\u7ba1\u7406\u54e1\u8a2d\u5b9a\u4ee5\u9032\u884c\u9a57\u8b49\u3002", "cannot_connect": "\u9023\u7dda\u5931\u6557", "id_missing": "\u4e09\u661f\u88dd\u7f6e\u4e26\u672a\u5305\u542b\u5e8f\u865f\u3002", + "missing_config_entry": "\u6b64\u4e09\u661f\u88dd\u7f6e\u4e26\u672a\u5305\u542b\u8a2d\u5b9a\u3002", "not_supported": "\u4e0d\u652f\u63f4\u6b64\u6b3e\u4e09\u661f\u88dd\u7f6e\u3002", "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index c57dd14e37d..09e6b4a4c0b 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -2,7 +2,7 @@ "domain": "scrape", "name": "Scrape", "documentation": "https://www.home-assistant.io/integrations/scrape", - "requirements": ["beautifulsoup4==4.9.3"], + "requirements": ["beautifulsoup4==4.10.0"], "after_dependencies": ["rest"], "codeowners": ["@fabaff"], "iot_class": "cloud_polling" diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index c8e4f84caf0..7e8a0dbf60b 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -69,15 +69,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for pump_num, pump_data in coordinator.data[SL_DATA.KEY_PUMPS].items(): if pump_data["data"] != 0 and "currentWatts" in pump_data: for pump_key in pump_data: - # Considerations for Intelliflow VF + enabled = True + # Assumptions for Intelliflow VF if pump_data["pumpType"] == 1 and pump_key == "currentRPM": - continue - # Considerations for Intelliflow VS + enabled = False + # Assumptions for Intelliflow VS if pump_data["pumpType"] == 2 and pump_key == "currentGPM": - continue + enabled = False if pump_key in SUPPORTED_PUMP_SENSORS: entities.append( - ScreenLogicPumpSensor(coordinator, pump_num, pump_key) + ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled) ) # IntelliChem sensors @@ -85,7 +86,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for chem_sensor_name in coordinator.data[SL_DATA.KEY_CHEMISTRY]: enabled = True if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - if chem_sensor_name in ("salt_tds_ppm"): + if chem_sensor_name in ("salt_tds_ppm",): enabled = False if chem_sensor_name in SUPPORTED_CHEM_SENSORS: entities.append( diff --git a/homeassistant/components/screenlogic/translations/he.json b/homeassistant/components/screenlogic/translations/he.json index 8e592e373e6..fdce873fdb2 100644 --- a/homeassistant/components/screenlogic/translations/he.json +++ b/homeassistant/components/screenlogic/translations/he.json @@ -13,6 +13,11 @@ "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", "port": "\u05e4\u05ea\u05d7\u05d4" } + }, + "gateway_select": { + "data": { + "selected_gateway": "\u05e9\u05e2\u05e8" + } } } } diff --git a/homeassistant/components/screenlogic/translations/hu.json b/homeassistant/components/screenlogic/translations/hu.json index 3efb8ec6e60..6781f477ab6 100644 --- a/homeassistant/components/screenlogic/translations/hu.json +++ b/homeassistant/components/screenlogic/translations/hu.json @@ -13,7 +13,7 @@ "ip_address": "IP c\u00edm", "port": "Port" }, - "description": "Add meg a ScreenLogic Gateway adatait.", + "description": "Adja meg a ScreenLogic Gateway adatait.", "title": "ScreenLogic" }, "gateway_select": { diff --git a/homeassistant/components/screenlogic/translations/id.json b/homeassistant/components/screenlogic/translations/id.json index 5af1cfbe5ef..b0052f6f0f7 100644 --- a/homeassistant/components/screenlogic/translations/id.json +++ b/homeassistant/components/screenlogic/translations/id.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "ScreenLogic {name}", + "flow_title": "{name}", "step": { "gateway_entry": { "data": { diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index ded3ff4bc24..6dabacf34e5 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers.state import ( CONF_FOR, @@ -64,7 +67,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" state_config = { diff --git a/homeassistant/components/select/translations/pl.json b/homeassistant/components/select/translations/pl.json index 102cfa68534..3d19c05a80a 100644 --- a/homeassistant/components/select/translations/pl.json +++ b/homeassistant/components/select/translations/pl.json @@ -4,10 +4,10 @@ "select_option": "Zmie\u0144 opcj\u0119 {entity_name}" }, "condition_type": { - "selected_option": "Aktualnie wybrana opcja dla {entity_name}" + "selected_option": "aktualnie wybrana opcja dla {entity_name}" }, "trigger_type": { - "current_option_changed": "Zmieniono opcj\u0119 {entity_name}" + "current_option_changed": "zmieniono opcj\u0119 {entity_name}" } }, "title": "Wybierz" diff --git a/homeassistant/components/select/translations/zh-Hans.json b/homeassistant/components/select/translations/zh-Hans.json index 4857f798f3b..668d85bb2b0 100644 --- a/homeassistant/components/select/translations/zh-Hans.json +++ b/homeassistant/components/select/translations/zh-Hans.json @@ -10,5 +10,5 @@ "current_option_changed": "{entity_name} \u7684\u9009\u9879\u53d8\u5316" } }, - "title": "\u9009\u5b9a" + "title": "\u9009\u62e9\u5668" } \ No newline at end of file diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json index 216ea5f625b..d31feb5a8e4 100644 --- a/homeassistant/components/sendgrid/manifest.json +++ b/homeassistant/components/sendgrid/manifest.json @@ -2,7 +2,7 @@ "domain": "sendgrid", "name": "SendGrid", "documentation": "https://www.home-assistant.io/integrations/sendgrid", - "requirements": ["sendgrid==6.7.0"], + "requirements": ["sendgrid==6.8.2"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/sense/translations/fr.json b/homeassistant/components/sense/translations/fr.json index bf5509a44a8..83ec49fd388 100644 --- a/homeassistant/components/sense/translations/fr.json +++ b/homeassistant/components/sense/translations/fr.json @@ -4,14 +4,14 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "email": "Adresse e-mail", + "email": "Email", "password": "Mot de passe" }, "title": "Connectez-vous \u00e0 votre moniteur d'\u00e9nergie Sense" diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 8dc74ae4e08..10f86609ae2 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -62,7 +62,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def get_cpu_temp(): """Get CPU temperature.""" - t_cpu = Path("/sys/class/thermal/thermal_zone0/temp").read_text().strip() + t_cpu = ( + Path("/sys/class/thermal/thermal_zone0/temp") + .read_text(encoding="utf-8") + .strip() + ) return float(t_cpu) * 0.001 diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 413d9d2152f..91bff740ffd 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO, DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DATE, DEVICE_CLASS_ENERGY, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, @@ -51,6 +52,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType +from .const import CONF_STATE_CLASS # noqa: F401 + _LOGGER: Final = logging.getLogger(__name__) ATTR_LAST_RESET: Final = "last_reset" # Deprecated, to be removed in 2021.11 @@ -67,14 +70,15 @@ DEVICE_CLASSES: Final[list[str]] = [ DEVICE_CLASS_CO, # ppm (parts per million) Carbon Monoxide gas concentration DEVICE_CLASS_CO2, # ppm (parts per million) Carbon Dioxide gas concentration DEVICE_CLASS_CURRENT, # current (A) + DEVICE_CLASS_DATE, # date (ISO8601) DEVICE_CLASS_ENERGY, # energy (kWh, Wh) DEVICE_CLASS_HUMIDITY, # % of humidity in the air DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) DEVICE_CLASS_MONETARY, # Amount of money (currency) DEVICE_CLASS_OZONE, # Amount of O3 (µg/m³) DEVICE_CLASS_NITROGEN_DIOXIDE, # Amount of NO2 (µg/m³) - DEVICE_CLASS_NITROUS_OXIDE, # Amount of NO (µg/m³) - DEVICE_CLASS_NITROGEN_MONOXIDE, # Amount of N2O (µg/m³) + DEVICE_CLASS_NITROUS_OXIDE, # Amount of N2O (µg/m³) + DEVICE_CLASS_NITROGEN_MONOXIDE, # Amount of NO (µg/m³) DEVICE_CLASS_PM1, # Particulate matter <= 0.1 μm (µg/m³) DEVICE_CLASS_PM10, # Particulate matter <= 10 μm (µg/m³) DEVICE_CLASS_PM25, # Particulate matter <= 2.5 μm (µg/m³) @@ -94,11 +98,14 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) # The state represents a measurement in present time STATE_CLASS_MEASUREMENT: Final = "measurement" +# The state represents a total amount, e.g. net energy consumption +STATE_CLASS_TOTAL: Final = "total" # The state represents a monotonically increasing total, e.g. an amount of consumed gas STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [ STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ] @@ -209,15 +216,13 @@ class SensorEntity(Entity): and not self._last_reset_reported ): self._last_reset_reported = True - if self.platform and self.platform.platform_name == "energy": - return {ATTR_LAST_RESET: last_reset.isoformat()} - report_issue = self._suggest_report_issue() _LOGGER.warning( "Entity %s (%s) with state_class %s has set last_reset. Setting " - "last_reset is deprecated and will be unsupported from Home " - "Assistant Core 2021.11. Please update your configuration if " - "state_class is manually configured, otherwise %s", + "last_reset for entities with state_class other than 'total' is " + "deprecated and will be removed from Home Assistant Core 2021.11. " + "Please update your configuration if state_class is manually " + "configured, otherwise %s", self.entity_id, type(self), self.state_class, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py new file mode 100644 index 00000000000..54d683242ea --- /dev/null +++ b/homeassistant/components/sensor/const.py @@ -0,0 +1,4 @@ +"""Constants for sensor.""" +from typing import Final + +CONF_STATE_CLASS: Final = "state_class" diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e94cf0f6e46..c485622af80 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -1,13 +1,26 @@ """Statistics helper for sensor.""" from __future__ import annotations +from collections import defaultdict +from collections.abc import Callable, Iterable import datetime import itertools import logging import math -from typing import Callable -from homeassistant.components.recorder import history, statistics +from sqlalchemy.orm.session import Session + +from homeassistant.components.recorder import ( + history, + is_entity_recorded, + statistics, + util as recorder_util, +) +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMetaData, + StatisticResult, +) from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY, @@ -16,6 +29,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, ) @@ -40,6 +54,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util @@ -57,10 +72,12 @@ DEVICE_CLASS_STATISTICS: dict[str, dict[str, set[str]]] = { DEVICE_CLASS_GAS: {"sum"}, DEVICE_CLASS_MONETARY: {"sum"}, }, + STATE_CLASS_TOTAL: {}, STATE_CLASS_TOTAL_INCREASING: {}, } DEFAULT_STATISTICS = { STATE_CLASS_MEASUREMENT: {"mean", "min", "max"}, + STATE_CLASS_TOTAL: {"sum"}, STATE_CLASS_TOTAL_INCREASING: {"sum"}, } @@ -111,23 +128,26 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { # Keep track of entities for which a warning about decreasing value has been logged SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" +# Keep track of entities for which a warning about negative value has been logged +WARN_NEGATIVE = "sensor_warn_total_increasing_negative" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]: - """Get (entity_id, state_class, device_class) of all sensors for which to compile statistics.""" +def _get_sensor_states(hass: HomeAssistant) -> list[State]: + """Get the current state of all sensors for which to compile statistics.""" all_sensors = hass.states.all(DOMAIN) - entity_ids = [] + statistics_sensors = [] for state in all_sensors: - if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: + if not is_entity_recorded(hass, state.entity_id): continue - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - entity_ids.append((state.entity_id, state_class, device_class)) + if (state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: + continue + statistics_sensors.append(state) - return entity_ids + return statistics_sensors def _time_weighted_average( @@ -135,7 +155,7 @@ def _time_weighted_average( ) -> float: """Calculate a time weighted average. - The average is calculated by, weighting the states by duration in seconds between + The average is calculated by weighting the states by duration in seconds between state changes. Note: there's no interpolation of values between state changes. """ @@ -183,7 +203,9 @@ def _parse_float(state: str) -> float: def _normalize_states( hass: HomeAssistant, - entity_history: list[State], + session: Session, + old_metadatas: dict[str, tuple[int, StatisticMetaData]], + entity_history: Iterable[State], device_class: str | None, entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: @@ -208,10 +230,10 @@ def _normalize_states( if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id) extra = "" - if old_metadata := statistics.get_metadata(hass, entity_id): + if old_metadata := old_metadatas.get(entity_id): extra = ( " and matches the unit of already compiled statistics " - f"({old_metadata['unit_of_measurement']})" + f"({old_metadata[1]['unit_of_measurement']})" ) _LOGGER.warning( "The unit of %s is changing, got multiple %s, generation of long term " @@ -246,6 +268,24 @@ def _normalize_states( return DEVICE_CLASS_UNITS[device_class], fstates +def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: + """Suggest to report an issue.""" + domain = entity_sources(hass).get(entity_id, {}).get("domain") + custom_component = entity_sources(hass).get(entity_id, {}).get("custom_component") + report_issue = "" + if custom_component: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + if domain: + report_issue += f"+label%3A%22integration%3A+{domain}%22" + + return report_issue + + def warn_dip(hass: HomeAssistant, entity_id: str) -> None: """Log a warning once if a sensor with state_class_total has a decreasing value. @@ -267,11 +307,26 @@ def warn_dip(hass: HomeAssistant, entity_id: str) -> None: return _LOGGER.warning( "Entity %s %shas state class total_increasing, but its state is " - "not strictly increasing. Please create a bug report at %s", + "not strictly increasing. Please %s", entity_id, f"from integration {domain} " if domain else "", - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - "+label%3A%22integration%3A+recorder%22", + _suggest_report_issue(hass, entity_id), + ) + + +def warn_negative(hass: HomeAssistant, entity_id: str) -> None: + """Log a warning once if a sensor with state_class_total has a negative value.""" + if WARN_NEGATIVE not in hass.data: + hass.data[WARN_NEGATIVE] = set() + if entity_id not in hass.data[WARN_NEGATIVE]: + hass.data[WARN_NEGATIVE].add(entity_id) + domain = entity_sources(hass).get(entity_id, {}).get("domain") + _LOGGER.warning( + "Entity %s %shas state class total_increasing, but its state is " + "negative. Please %s", + entity_id, + f"from integration {domain} " if domain else "", + _suggest_report_issue(hass, entity_id), ) @@ -285,73 +340,122 @@ def reset_detected( if 0.9 * previous_state <= state < previous_state: warn_dip(hass, entity_id) + if state < 0: + warn_negative(hass, entity_id) + raise HomeAssistantError + return state < 0.9 * previous_state -def _wanted_statistics( - entities: list[tuple[str, str, str | None]] -) -> dict[str, set[str]]: +def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]: """Prepare a dict with wanted statistics for entities.""" wanted_statistics = {} - for entity_id, state_class, device_class in entities: + for state in sensor_states: + state_class = state.attributes[ATTR_STATE_CLASS] + device_class = state.attributes.get(ATTR_DEVICE_CLASS) if device_class in DEVICE_CLASS_STATISTICS[state_class]: - wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ + wanted_statistics[state.entity_id] = DEVICE_CLASS_STATISTICS[state_class][ device_class ] else: - wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class] + wanted_statistics[state.entity_id] = DEFAULT_STATISTICS[state_class] return wanted_statistics -def compile_statistics( # noqa: C901 +def _last_reset_as_utc_isoformat( + last_reset_s: str | None, entity_id: str +) -> str | None: + """Parse last_reset and convert it to UTC.""" + if last_reset_s is None: + return None + last_reset = dt_util.parse_datetime(last_reset_s) + if last_reset is None: + _LOGGER.warning( + "Ignoring invalid last reset '%s' for %s", last_reset_s, entity_id + ) + return None + return dt_util.as_utc(last_reset).isoformat() + + +def compile_statistics( hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime -) -> dict: +) -> list[StatisticResult]: """Compile statistics for all entities during start-end. Note: This will query the database and must not be run in the event loop """ - result: dict = {} + with recorder_util.session_scope(hass=hass) as session: + result = _compile_statistics(hass, session, start, end) + return result - entities = _get_entities(hass) - wanted_statistics = _wanted_statistics(entities) +def _compile_statistics( # noqa: C901 + hass: HomeAssistant, + session: Session, + start: datetime.datetime, + end: datetime.datetime, +) -> list[StatisticResult]: + """Compile statistics for all entities during start-end.""" + result: list[StatisticResult] = [] + + sensor_states = _get_sensor_states(hass) + wanted_statistics = _wanted_statistics(sensor_states) + old_metadatas = statistics.get_metadata_with_session( + hass, session, [i.entity_id for i in sensor_states], None + ) # Get history between start and end - entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]] + entities_full_history = [ + i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] + ] history_list = {} if entities_full_history: - history_list = history.get_significant_states( # type: ignore + history_list = history.get_significant_states_with_session( # type: ignore hass, + session, start - datetime.timedelta.resolution, end, entity_ids=entities_full_history, significant_changes_only=False, ) entities_significant_history = [ - i[0] for i in entities if "sum" not in wanted_statistics[i[0]] + i.entity_id + for i in sensor_states + if "sum" not in wanted_statistics[i.entity_id] ] if entities_significant_history: - _history_list = history.get_significant_states( # type: ignore + _history_list = history.get_significant_states_with_session( # type: ignore hass, + session, start - datetime.timedelta.resolution, end, entity_ids=entities_significant_history, ) history_list = {**history_list, **_history_list} + # If there are no recent state changes, the sensor's state may already be pruned + # from the recorder. Get the state from the state machine instead. + for _state in sensor_states: + if _state.entity_id not in history_list: + history_list[_state.entity_id] = (_state,) - for entity_id, state_class, device_class in entities: + for _state in sensor_states: # pylint: disable=too-many-nested-blocks + entity_id = _state.entity_id if entity_id not in history_list: continue + state_class = _state.attributes[ATTR_STATE_CLASS] + device_class = _state.attributes.get(ATTR_DEVICE_CLASS) entity_history = history_list[entity_id] - unit, fstates = _normalize_states(hass, entity_history, device_class, entity_id) + unit, fstates = _normalize_states( + hass, session, old_metadatas, entity_history, device_class, entity_id + ) if not fstates: continue # Check metadata - if old_metadata := statistics.get_metadata(hass, entity_id): - if old_metadata["unit_of_measurement"] != unit: + if old_metadata := old_metadatas.get(entity_id): + if old_metadata[1]["unit_of_measurement"] != unit: if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -362,26 +466,25 @@ def compile_statistics( # noqa: C901 "will be suppressed unless the unit changes back to %s", entity_id, unit, - old_metadata["unit_of_measurement"], - old_metadata["unit_of_measurement"], + old_metadata[1]["unit_of_measurement"], + old_metadata[1]["unit_of_measurement"], ) continue - result[entity_id] = {} - # Set meta data - result[entity_id]["meta"] = { + meta: StatisticMetaData = { + "statistic_id": entity_id, "unit_of_measurement": unit, "has_mean": "mean" in wanted_statistics[entity_id], "has_sum": "sum" in wanted_statistics[entity_id], } # Make calculations - stat: dict = {} + stat: StatisticData = {"start": start} if "max" in wanted_statistics[entity_id]: - stat["max"] = max(*itertools.islice(zip(*fstates), 1)) + stat["max"] = max(*itertools.islice(zip(*fstates), 1)) # type: ignore[typeddict-item] if "min" in wanted_statistics[entity_id]: - stat["min"] = min(*itertools.islice(zip(*fstates), 1)) + stat["min"] = min(*itertools.islice(zip(*fstates), 1)) # type: ignore[typeddict-item] if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(fstates, start, end) @@ -389,17 +492,17 @@ def compile_statistics( # noqa: C901 if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None new_state = old_state = None - _sum = 0 + _sum = 0.0 last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) 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"] new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] or 0 + _sum = last_stats[entity_id][0]["sum"] or 0.0 for fstate, state in fstates: - # Deprecated, will be removed in Home Assistant 2021.10 + # Deprecated, will be removed in Home Assistant 2021.11 if ( "last_reset" not in state.attributes and state_class == STATE_CLASS_MEASUREMENT @@ -409,8 +512,13 @@ def compile_statistics( # noqa: C901 reset = False if ( state_class != STATE_CLASS_TOTAL_INCREASING - and (last_reset := state.attributes.get("last_reset")) + and ( + last_reset := _last_reset_as_utc_isoformat( + state.attributes.get("last_reset"), entity_id + ) + ) != old_last_reset + and last_reset is not None ): if old_state is None: _LOGGER.info( @@ -433,17 +541,20 @@ def compile_statistics( # noqa: C901 entity_id, fstate, ) - elif state_class == STATE_CLASS_TOTAL_INCREASING and ( - old_state is None - or reset_detected(hass, entity_id, fstate, new_state) - ): - reset = True - _LOGGER.info( - "Detected new cycle for %s, value dropped from %s to %s", - entity_id, - new_state, - fstate, - ) + elif state_class == STATE_CLASS_TOTAL_INCREASING: + try: + if old_state is None or reset_detected( + hass, entity_id, fstate, new_state + ): + reset = True + _LOGGER.info( + "Detected new cycle for %s, value dropped from %s to %s", + entity_id, + new_state, + fstate, + ) + except HomeAssistantError: + continue if reset: # The sensor has been reset, update the sum @@ -463,12 +574,10 @@ def compile_statistics( # noqa: C901 # Deprecated, will be removed in Home Assistant 2021.11 if last_reset is None and state_class == STATE_CLASS_MEASUREMENT: # No valid updates - result.pop(entity_id) continue if new_state is None or old_state is None: # No valid updates - result.pop(entity_id) continue # Update the sum with the last state @@ -478,18 +587,22 @@ def compile_statistics( # noqa: C901 stat["sum"] = _sum stat["state"] = new_state - result[entity_id]["stat"] = stat + result.append({"meta": meta, "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) + entities = _get_sensor_states(hass) statistic_ids = {} - for entity_id, state_class, device_class in entities: + for state in entities: + state_class = state.attributes[ATTR_STATE_CLASS] + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if device_class in DEVICE_CLASS_STATISTICS[state_class]: provided_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] else: @@ -498,9 +611,6 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - 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 @@ -508,20 +618,95 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - ): continue - metadata = statistics.get_metadata(hass, entity_id) - if metadata: - native_unit: str | None = metadata["unit_of_measurement"] - else: - native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if device_class not in UNIT_CONVERSIONS: - statistic_ids[entity_id] = native_unit + statistic_ids[state.entity_id] = native_unit continue if native_unit not in UNIT_CONVERSIONS[device_class]: continue statistics_unit = DEVICE_CLASS_UNITS[device_class] - statistic_ids[entity_id] = statistics_unit + statistic_ids[state.entity_id] = statistics_unit return statistic_ids + + +def validate_statistics( + hass: HomeAssistant, +) -> dict[str, list[statistics.ValidationIssue]]: + """Validate statistics.""" + validation_result = defaultdict(list) + + sensor_states = hass.states.all(DOMAIN) + metadatas = statistics.get_metadata(hass, [i.entity_id for i in sensor_states]) + + for state in sensor_states: + entity_id = state.entity_id + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + state_class = state.attributes.get(ATTR_STATE_CLASS) + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if metadata := metadatas.get(entity_id): + if not is_entity_recorded(hass, state.entity_id): + # Sensor was previously recorded, but no longer is + validation_result[entity_id].append( + statistics.ValidationIssue( + "entity_not_recorded", + {"statistic_id": entity_id}, + ) + ) + + if state_class not in STATE_CLASSES: + # Sensor no longer has a valid state class + validation_result[entity_id].append( + statistics.ValidationIssue( + "unsupported_state_class", + {"statistic_id": entity_id, "state_class": state_class}, + ) + ) + + metadata_unit = metadata[1]["unit_of_measurement"] + if device_class not in UNIT_CONVERSIONS: + if state_unit != metadata_unit: + # The unit has changed + validation_result[entity_id].append( + statistics.ValidationIssue( + "units_changed", + { + "statistic_id": entity_id, + "state_unit": state_unit, + "metadata_unit": metadata_unit, + }, + ) + ) + elif metadata_unit != DEVICE_CLASS_UNITS[device_class]: + # The unit in metadata is not supported for this device class + validation_result[entity_id].append( + statistics.ValidationIssue( + "unsupported_unit_metadata", + { + "statistic_id": entity_id, + "device_class": device_class, + "metadata_unit": metadata_unit, + "supported_unit": DEVICE_CLASS_UNITS[device_class], + }, + ) + ) + + if ( + device_class in UNIT_CONVERSIONS + and state_unit not in UNIT_CONVERSIONS[device_class] + ): + # The unit in the state is not supported for this device class + validation_result[entity_id].append( + statistics.ValidationIssue( + "unsupported_unit_state", + { + "statistic_id": entity_id, + "device_class": device_class, + "state_unit": state_unit, + }, + ) + ) + + return validation_result diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index 1b041b576fc..a042e3102f9 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -13,8 +13,8 @@ "is_nitrogen_monoxide": "Aktuelle Stickstoffmonoxidkonzentration von {entity_name}", "is_nitrous_oxide": "Aktuelle Lachgaskonzentration von {entity_name}", "is_ozone": "Aktuelle Ozonkonzentration von {entity_name}", - "is_pm1": "Aktuelle PM1-Konzentrationswert von {entity_name}", - "is_pm10": "Aktuelle PM10-Konzentrationswert von {entity_name}", + "is_pm1": "Aktuelle PM1-Konzentration von {entity_name}", + "is_pm10": "Aktuelle PM10-Konzentration von {entity_name}", "is_pm25": "Aktuelle PM2.5-Konzentration von {entity_name}", "is_power": "Aktuelle {entity_name} Leistung", "is_power_factor": "Aktueller Leistungsfaktor f\u00fcr {entity_name}", diff --git a/homeassistant/components/sensor/translations/el.json b/homeassistant/components/sensor/translations/el.json index 21bcb9e378c..ff8750f52dc 100644 --- a/homeassistant/components/sensor/translations/el.json +++ b/homeassistant/components/sensor/translations/el.json @@ -1,4 +1,24 @@ { + "device_automation": { + "condition_type": { + "is_gas": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b1\u03ad\u03c1\u03b9\u03bf {entity_name}", + "is_pm25": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 {entity_name} PM2.5", + "is_sulphur_dioxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5 {entity_name}", + "is_volatile_organic_compounds": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03c0\u03c4\u03b7\u03c4\u03b9\u03ba\u03ce\u03bd \u03bf\u03c1\u03b3\u03b1\u03bd\u03b9\u03ba\u03ce\u03bd \u03b5\u03bd\u03ce\u03c3\u03b5\u03c9\u03bd {entity_name}" + }, + "trigger_type": { + "gas": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03b1\u03b5\u03c1\u03af\u03bf\u03c5", + "nitrogen_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b1\u03b6\u03ce\u03c4\u03bf\u03c5", + "nitrogen_monoxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03bc\u03bf\u03bd\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b1\u03b6\u03ce\u03c4\u03bf\u03c5", + "nitrous_oxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b1\u03b6\u03ce\u03c4\u03bf\u03c5", + "ozone": "{entity_name} \u03b1\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03cc\u03b6\u03bf\u03bd\u03c4\u03bf\u03c2", + "pm1": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM1", + "pm10": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM10", + "pm25": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 PM2.5", + "sulphur_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5", + "volatile_organic_compounds": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03c4\u03c9\u03bd \u03c0\u03c4\u03b7\u03c4\u03b9\u03ba\u03ce\u03bd \u03bf\u03c1\u03b3\u03b1\u03bd\u03b9\u03ba\u03ce\u03bd \u03b5\u03bd\u03ce\u03c3\u03b5\u03c9\u03bd {entity_name}" + } + }, "state": { "_": { "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", diff --git a/homeassistant/components/sensor/translations/es.json b/homeassistant/components/sensor/translations/es.json index 48c61f321a1..135aada7f44 100644 --- a/homeassistant/components/sensor/translations/es.json +++ b/homeassistant/components/sensor/translations/es.json @@ -26,6 +26,7 @@ "gas": "Cambio de gas de {entity_name}", "humidity": "Cambios de humedad de {entity_name}", "illuminance": "Cambios de luminosidad de {entity_name}", + "nitrogen_monoxide": "Cambios en la concentraci\u00f3n de mon\u00f3xido de nitr\u00f3geno de {entity_name}", "power": "Cambios de potencia de {entity_name}", "power_factor": "Cambio de factor de potencia en {entity_name}", "pressure": "Cambios de presi\u00f3n de {entity_name}", diff --git a/homeassistant/components/sensor/translations/fr.json b/homeassistant/components/sensor/translations/fr.json index 1aeee79ddeb..d25cb4766cb 100644 --- a/homeassistant/components/sensor/translations/fr.json +++ b/homeassistant/components/sensor/translations/fr.json @@ -14,6 +14,7 @@ "is_signal_strength": "Force du signal de {entity_name}", "is_temperature": "Temp\u00e9rature de {entity_name}", "is_value": "La valeur actuelle de {entity_name}", + "is_volatile_organic_compounds": "Niveau actuel de concentration en compos\u00e9s organiques volatils de {entity_name}", "is_voltage": "Tension actuelle pour {entity_name}" }, "trigger_type": { @@ -30,6 +31,7 @@ "signal_strength": "{entity_name} modification de la force du signal", "temperature": "{entity_name} modification de temp\u00e9rature", "value": "Changements de valeur de {entity_name}", + "volatile_organic_compounds": "{entity_name} changements de concentration de compos\u00e9s organiques volatils", "voltage": "{entity_name} changement de tension" } }, diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 58ecdea0f24..1e33cc18355 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "A {entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3 jelenlegi szintje", "is_temperature": "{entity_name} aktu\u00e1lis h\u0151m\u00e9rs\u00e9klete", "is_value": "{entity_name} aktu\u00e1lis \u00e9rt\u00e9ke", + "is_volatile_organic_compounds": "Jelenlegi {entity_name} ill\u00e9kony szerves vegy\u00fcletek koncentr\u00e1ci\u00f3s szintje", "is_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "{entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3v\u00e1ltoz\u00e1s", "temperature": "{entity_name} h\u0151m\u00e9rs\u00e9klete v\u00e1ltozik", "value": "{entity_name} \u00e9rt\u00e9ke v\u00e1ltozik", + "volatile_organic_compounds": "{entity_name} ill\u00e9kony szerves vegy\u00fcletek koncentr\u00e1ci\u00f3j\u00e1nak v\u00e1ltoz\u00e1sai", "voltage": "{entity_name} fesz\u00fclts\u00e9ge v\u00e1ltozik" } }, diff --git a/homeassistant/components/sensor/translations/id.json b/homeassistant/components/sensor/translations/id.json index 9af162d1357..cea3f890430 100644 --- a/homeassistant/components/sensor/translations/id.json +++ b/homeassistant/components/sensor/translations/id.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Level konsentasi karbonmonoksida {entity_name} saat ini", "is_current": "Arus {entity_name} saat ini", "is_energy": "Energi {entity_name} saat ini", + "is_gas": "Gas {entity_name} saat ini", "is_humidity": "Kelembaban {entity_name} saat ini", "is_illuminance": "Pencahayaan {entity_name} saat ini", + "is_nitrogen_dioxide": "Tingkat konsentrasi nitrogen dioksida {entity_name} saat ini", + "is_nitrogen_monoxide": "Tingkat konsentrasi nitrogen monoksida {entity_name} saat ini", + "is_nitrous_oxide": "Tingkat konsentrasi nitrit oksida {entity_name} saat ini", + "is_ozone": "Tingkat konsentrasi ozon {entity_name} saat ini", + "is_pm1": "Tingkat konsentrasi PM1 {entity_name} saat ini", + "is_pm10": "Tingkat konsentrasi PM10 {entity_name} saat ini", + "is_pm25": "Tingkat konsentrasi PM2.5 {entity_name} saat ini", "is_power": "Daya {entity_name} saat ini", "is_power_factor": "Faktor daya {entity_name} saat ini", "is_pressure": "Tekanan {entity_name} saat ini", "is_signal_strength": "Kekuatan sinyal {entity_name} saat ini", + "is_sulphur_dioxide": "Tingkat konsentrasi sulfur dioksida {entity_name} saat ini", "is_temperature": "Suhu {entity_name} saat ini", "is_value": "Nilai {entity_name} saat ini", + "is_volatile_organic_compounds": "Tingkat konsentrasi senyawa organik volatil {entity_name} saat ini", "is_voltage": "Tegangan {entity_name} saat ini" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "Perubahan konsentrasi karbonmonoksida {entity_name}", "current": "Perubahan arus {entity_name}", "energy": "Perubahan energi {entity_name}", + "gas": "Perubahan gas {entity_name}", "humidity": "Perubahan kelembaban {entity_name}", "illuminance": "Perubahan pencahayaan {entity_name}", + "nitrogen_dioxide": "Perubahan konsentrasi nitrogen dioksida {entity_name}", + "nitrogen_monoxide": "Perubahan konsentrasi nitrogen monoksida {entity_name}", + "nitrous_oxide": "Perubahan konsentrasi nitro oksida {entity_name}", + "ozone": "Perubahan konsentrasi ozon {entity_name}", + "pm1": "Perubahan konsentrasi PM1 {entity_name}", + "pm10": "Perubahan konsentrasi PM10 {entity_name}", + "pm25": "Perubahan konsentrasi PM2.5 {entity_name}", "power": "Perubahan daya {entity_name}", "power_factor": "Perubahan faktor daya {entity_name}", "pressure": "Perubahan tekanan {entity_name}", "signal_strength": "Perubahan kekuatan sinyal {entity_name}", + "sulphur_dioxide": "Perubahan konsentrasi sulfur dioksida {entity_name}", "temperature": "Perubahan suhu {entity_name}", "value": "Perubahan nilai {entity_name}", + "volatile_organic_compounds": "Perubahan konsentrasi senyawa organik volatil {entity_name}", "voltage": "Perubahan tegangan {entity_name}" } }, diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json index 7b9b483c024..cc5d3534715 100644 --- a/homeassistant/components/sensor/translations/it.json +++ b/homeassistant/components/sensor/translations/it.json @@ -23,6 +23,7 @@ "is_sulphur_dioxide": "Attuale livello di concentrazione di anidride solforosa di {entity_name}", "is_temperature": "Temperatura attuale di {entity_name}", "is_value": "Valore attuale di {entity_name}", + "is_volatile_organic_compounds": "Attuale livello di concentrazione di composti organici volatili di {entity_name}", "is_voltage": "Tensione attuale di {entity_name}" }, "trigger_type": { @@ -48,6 +49,7 @@ "sulphur_dioxide": "Variazioni della concentrazione di anidride solforosa di {entity_name}", "temperature": "variazioni di temperatura di {entity_name}", "value": "{entity_name} valori cambiati", + "volatile_organic_compounds": "Variazioni della concentrazione di composti organici volatili di {entity_name}", "voltage": "variazioni di tensione di {entity_name}" } }, diff --git a/homeassistant/components/sensor/translations/pl.json b/homeassistant/components/sensor/translations/pl.json index 2a82919e42e..def1be5e06d 100644 --- a/homeassistant/components/sensor/translations/pl.json +++ b/homeassistant/components/sensor/translations/pl.json @@ -23,31 +23,33 @@ "is_sulphur_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku siarki {entity_name}", "is_temperature": "obecna temperatura {entity_name}", "is_value": "obecna warto\u015b\u0107 {entity_name}", + "is_volatile_organic_compounds": "obecny poziom st\u0119\u017cenia lotnych zwi\u0105zk\u00f3w organicznych {entity_name}", "is_voltage": "obecne napi\u0119cie {entity_name}" }, "trigger_type": { "battery_level": "zmieni si\u0119 poziom baterii {entity_name}", - "carbon_dioxide": "Zmiana st\u0119\u017cenie dwutlenku w\u0119gla w {entity_name}", - "carbon_monoxide": "Zmiana st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", + "carbon_dioxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia dwutlenku w\u0119gla", + "carbon_monoxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia tlenku w\u0119gla", "current": "zmieni si\u0119 nat\u0119\u017cenie pr\u0105du w {entity_name}", "energy": "zmieni si\u0119 energia {entity_name}", - "gas": "zmieni si\u0119 poziom gazu w {entity_name}", + "gas": "{entity_name} wykryje zmian\u0119 poziomu gazu", "humidity": "zmieni si\u0119 wilgotno\u015b\u0107 {entity_name}", "illuminance": "zmieni si\u0119 nat\u0119\u017cenie o\u015bwietlenia {entity_name}", "nitrogen_dioxide": "zmieni si\u0119 st\u0119\u017cenie dwutlenku azotu w {entity_name}", - "nitrogen_monoxide": "zmieni si\u0119 st\u0119\u017cenie tlenku azotu w {entity_name}", - "nitrous_oxide": "zmieni si\u0119 st\u0119\u017cenie podtlenku azotu w {entity_name}", - "ozone": "zmieni si\u0119 st\u0119\u017cenie ozonu w {entity_name}", - "pm1": "zmieni si\u0119 st\u0119\u017cenie PM1 w {entity_name}", - "pm10": "zmieni si\u0119 st\u0119\u017cenie PM10 w {entity_name}", - "pm25": "zmieni si\u0119 st\u0119\u017cenie PM2.5 w {entity_name}", + "nitrogen_monoxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia tlenku azotu", + "nitrous_oxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia podtlenku azotu", + "ozone": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia ozonu", + "pm1": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia PM1", + "pm10": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia PM10", + "pm25": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia PM2.5", "power": "zmieni si\u0119 moc {entity_name}", "power_factor": "zmieni si\u0119 wsp\u00f3\u0142czynnik mocy w {entity_name}", "pressure": "zmieni si\u0119 ci\u015bnienie {entity_name}", "signal_strength": "zmieni si\u0119 si\u0142a sygna\u0142u {entity_name}", - "sulphur_dioxide": "zmieni si\u0119 st\u0119\u017cenie dwutlenku siarki w {entity_name}", + "sulphur_dioxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia dwutlenku siarki", "temperature": "zmieni si\u0119 temperatura {entity_name}", "value": "zmieni si\u0119 warto\u015b\u0107 {entity_name}", + "volatile_organic_compounds": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia lotnych zwi\u0105zk\u00f3w organicznych", "voltage": "zmieni si\u0119 napi\u0119cie w {entity_name}" } }, diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index d6ddf61f19a..8f6ebb57603 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.3.0"], + "requirements": ["sentry-sdk==1.4.1"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/sentry/translations/hu.json b/homeassistant/components/sentry/translations/hu.json index df07c41449e..9c28d57eb5d 100644 --- a/homeassistant/components/sentry/translations/hu.json +++ b/homeassistant/components/sentry/translations/hu.json @@ -12,7 +12,7 @@ "data": { "dsn": "DSN" }, - "description": "Add meg a Sentry DSN-t", + "description": "Adja meg a Sentry DSN-t", "title": "Sentry" } } diff --git a/homeassistant/components/sharkiq/translations/ca.json b/homeassistant/components/sharkiq/translations/ca.json index 9ae6a703835..70402446062 100644 --- a/homeassistant/components/sharkiq/translations/ca.json +++ b/homeassistant/components/sharkiq/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" diff --git a/homeassistant/components/sharkiq/translations/fr.json b/homeassistant/components/sharkiq/translations/fr.json index 6fa3ba7707c..552715f0d1c 100644 --- a/homeassistant/components/sharkiq/translations/fr.json +++ b/homeassistant/components/sharkiq/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion", - "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 48e27203288..b0df4d4cb7f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -7,6 +7,8 @@ import logging from typing import Any, Final, cast import aioshelly +from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import async_timeout import voluptuous as vol @@ -29,8 +31,9 @@ from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, ATTR_DEVICE, + ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - COAP, + BLOCK, CONF_COAP_PORT, DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, @@ -41,14 +44,24 @@ from .const import ( POLLING_TIMEOUT_SEC, REST, REST_SENSORS_UPDATE_INTERVAL, + RPC, + RPC_INPUTS_EVENTS_TYPES, + RPC_RECONNECT_INTERVAL, SHBTN_MODELS, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, ) -from .utils import get_coap_context, get_device_name, get_device_sleep_period +from .utils import ( + get_block_device_name, + get_block_device_sleep_period, + get_coap_context, + get_device_entry_gen, + get_rpc_device_name, +) -PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] -SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] +BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +RPC_PLATFORMS: Final = ["binary_sensor", "light", "sensor", "switch"] _LOGGER: Final = logging.getLogger(__name__) COAP_SCHEMA: Final = vol.Schema( @@ -87,9 +100,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + if get_device_entry_gen(entry) == 2: + return await async_setup_rpc_entry(hass, entry) + + return await async_setup_block_entry(hass, entry) + + +async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shelly block based device from a config entry.""" temperature_unit = "C" if hass.config.units.is_metric else "F" - options = aioshelly.ConnectionOptions( + options = aioshelly.common.ConnectionOptions( entry.data[CONF_HOST], entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), @@ -98,14 +119,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coap_context = await get_coap_context(hass) - device = await aioshelly.Device.create( + device = await BlockDevice.create( aiohttp_client.async_get_clientsession(hass), coap_context, options, False, ) - dev_reg = await device_registry.async_get_registry(hass) + dev_reg = device_registry.async_get(hass) device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( @@ -123,22 +144,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if sleep_period is None: data = {**entry.data} - data["sleep_period"] = get_device_sleep_period(device.settings) + data["sleep_period"] = get_block_device_sleep_period(device.settings) data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) - hass.async_create_task(async_device_setup(hass, entry, device)) + hass.async_create_task(async_block_device_setup(hass, entry, device)) if sleep_period == 0: # Not a sleeping device, finish setup - _LOGGER.debug("Setting up online device %s", entry.title) + _LOGGER.debug("Setting up online block device %s", entry.title) try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - await device.initialize(True) + await device.initialize() except (asyncio.TimeoutError, OSError) as err: raise ConfigEntryNotReady from err - await async_device_setup(hass, entry, device) + await async_block_device_setup(hass, entry, device) elif sleep_period is None or device_entry is None: # Need to get sleep info or first time sleeping device setup, wait for device hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device @@ -146,40 +167,66 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Setup for device %s will resume when device is online", entry.title ) device.subscribe_updates(_async_device_online) - await device.coap_request("s") else: # Restore sensors for sleeping device - _LOGGER.debug("Setting up offline device %s", entry.title) - await async_device_setup(hass, entry, device) + _LOGGER.debug("Setting up offline block device %s", entry.title) + await async_block_device_setup(hass, entry, device) return True -async def async_device_setup( - hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device +async def async_block_device_setup( + hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice ) -> None: - """Set up a device that is online.""" + """Set up a block based device that is online.""" device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, entry, device) - await device_wrapper.async_setup() + BLOCK + ] = BlockDeviceWrapper(hass, entry, device) + device_wrapper.async_setup() - platforms = SLEEPING_PLATFORMS + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get("sleep_period"): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ REST ] = ShellyDeviceRestWrapper(hass, device) - platforms = PLATFORMS + platforms = BLOCK_PLATFORMS hass.config_entries.async_setup_platforms(entry, platforms) -class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): - """Wrapper for a Shelly device with Home Assistant specific functions.""" +async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shelly RPC based device from a config entry.""" + options = aioshelly.common.ConnectionOptions( + entry.data[CONF_HOST], + entry.data.get(CONF_USERNAME), + entry.data.get(CONF_PASSWORD), + ) + + _LOGGER.debug("Setting up online RPC device %s", entry.title) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + device = await RpcDevice.create( + aiohttp_client.async_get_clientsession(hass), options + ) + except (asyncio.TimeoutError, OSError) as err: + raise ConfigEntryNotReady from err + + device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + RPC + ] = RpcDeviceWrapper(hass, entry, device) + device_wrapper.async_setup() + + hass.config_entries.async_setup_platforms(entry, RPC_PLATFORMS) + + return True + + +class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly block based device with Home Assistant specific functions.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device + self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice ) -> None: """Initialize the Shelly device wrapper.""" self.device_id: str | None = None @@ -192,7 +239,9 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] ) - device_name = get_device_name(device) if device.initialized else entry.title + device_name = ( + get_block_device_name(device) if device.initialized else entry.title + ) super().__init__( hass, _LOGGER, @@ -203,12 +252,14 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.entry = entry self.device = device - self._async_remove_device_updates_handler = self.async_add_listener( - self._async_device_updates_handler + entry.async_on_unload( + self.async_add_listener(self._async_device_updates_handler) ) self._last_input_events_count: dict = {} - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) @callback def _async_device_updates_handler(self) -> None: @@ -216,6 +267,8 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): if not self.device.initialized: return + assert self.device.blocks + # For buttons which are battery powered - set initial value for last_event_count if self.model in SHBTN_MODELS and self._last_input_events_count.get(1) is None: for block in self.device.blocks: @@ -255,6 +308,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): ATTR_DEVICE: self.device.settings["device"]["hostname"], ATTR_CHANNEL: channel, ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type], + ATTR_GENERATION: 1, }, ) else: @@ -266,11 +320,13 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self) -> None: """Fetch data.""" - if self.entry.data.get("sleep_period"): + if sleep_period := self.entry.data.get("sleep_period"): # Sleeping device, no point polling it, just mark it unavailable - raise update_coordinator.UpdateFailed("Sleeping device did not update") + raise update_coordinator.UpdateFailed( + f"Sleeping device did not update within {sleep_period} seconds interval" + ) - _LOGGER.debug("Polling Shelly Device - %s", self.name) + _LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: async with async_timeout.timeout(POLLING_TIMEOUT_SEC): await self.device.update() @@ -287,18 +343,16 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Mac address of the device.""" return cast(str, self.entry.unique_id) - async def async_setup(self) -> None: + 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 "" + dev_reg = device_registry.async_get(self.hass) + sw_version = self.device.firmware_version if self.device.initialized else "" entry = dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, name=self.name, connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, - # This is duplicate but otherwise via_device can't work - identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=aioshelly.MODEL_NAMES.get(self.model, self.model), + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=sw_version, ) self.device_id = entry.id @@ -306,22 +360,19 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): def shutdown(self) -> None: """Shutdown the wrapper.""" - if self.device: - self.device.shutdown() - self._async_remove_device_updates_handler() - self.device = None + self.device.shutdown() @callback def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" - _LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name) + _LOGGER.debug("Stopping BlockDeviceWrapper for %s", self.name) self.shutdown() class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): """Rest Wrapper for a Shelly device with Home Assistant specific functions.""" - def __init__(self, hass: HomeAssistant, device: aioshelly.Device) -> None: + def __init__(self, hass: HomeAssistant, device: BlockDevice) -> None: """Initialize the Shelly device wrapper.""" if ( device.settings["device"]["type"] @@ -336,7 +387,7 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): super().__init__( hass, _LOGGER, - name=get_device_name(device), + name=get_block_device_name(device), update_interval=timedelta(seconds=update_interval), ) self.device = device @@ -358,39 +409,171 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if get_device_entry_gen(entry) == 2: + unload_ok = await hass.config_entries.async_unload_platforms( + entry, RPC_PLATFORMS + ) + if unload_ok: + await hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + + return unload_ok + device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) if device is not None: # If device is present, device wrapper is not setup yet device.shutdown() return True - platforms = SLEEPING_PLATFORMS + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get("sleep_period"): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None - platforms = PLATFORMS + platforms = BLOCK_PLATFORMS unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][COAP].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][BLOCK].shutdown() hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok -def get_device_wrapper( +def get_block_device_wrapper( hass: HomeAssistant, device_id: str -) -> ShellyDeviceWrapper | None: - """Get a Shelly device wrapper for the given device id.""" +) -> BlockDeviceWrapper | None: + """Get a Shelly block 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) + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry): + continue - if wrapper and wrapper.device_id == device_id: - return wrapper + if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(BLOCK): + return cast(BlockDeviceWrapper, wrapper) return None + + +def get_rpc_device_wrapper( + hass: HomeAssistant, device_id: str +) -> RpcDeviceWrapper | None: + """Get a Shelly RPC device wrapper for the given device id.""" + if not hass.data.get(DOMAIN): + return None + + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry): + continue + + if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(RPC): + return cast(RpcDeviceWrapper, wrapper) + + return None + + +class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly RPC based device with Home Assistant specific functions.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice + ) -> None: + """Initialize the Shelly device wrapper.""" + self.device_id: str | None = None + + device_name = get_rpc_device_name(device) if device.initialized else entry.title + super().__init__( + hass, + _LOGGER, + name=device_name, + update_interval=timedelta(seconds=RPC_RECONNECT_INTERVAL), + ) + self.entry = entry + self.device = device + + entry.async_on_unload( + self.async_add_listener(self._async_device_updates_handler) + ) + self._last_event: dict[str, Any] | None = None + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + + @callback + def _async_device_updates_handler(self) -> None: + """Handle device updates.""" + if ( + not self.device.initialized + or not self.device.event + or self.device.event == self._last_event + ): + return + + self._last_event = self.device.event + + for event in self.device.event["events"]: + if event.get("event") not in RPC_INPUTS_EVENTS_TYPES: + continue + + self.hass.bus.async_fire( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: self.device_id, + ATTR_DEVICE: self.device.hostname, + ATTR_CHANNEL: event["id"] + 1, + ATTR_CLICK_TYPE: event["event"], + ATTR_GENERATION: 2, + }, + ) + + async def _async_update_data(self) -> None: + """Fetch data.""" + if self.device.connected: + return + + try: + _LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + await self.device.initialize() + except OSError as err: + raise update_coordinator.UpdateFailed("Device disconnected") from err + + @property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + def async_setup(self) -> None: + """Set up the wrapper.""" + dev_reg = device_registry.async_get(self.hass) + sw_version = self.device.firmware_version if self.device.initialized else "" + entry = dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.name, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, + manufacturer="Shelly", + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + sw_version=sw_version, + ) + self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) + + async def shutdown(self) -> None: + """Shutdown the wrapper.""" + await self.device.shutdown() + + async def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + _LOGGER.debug("Stopping RpcDeviceWrapper for %s", self.name) + await self.shutdown() diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index f4b2daf8159..46e5468c079 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -24,13 +24,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( BlockAttributeDescription, RestAttributeDescription, + RpcAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, + async_setup_entry_rpc, +) +from .utils import ( + get_device_entry_gen, + is_block_momentary_input, + is_rpc_momentary_input, ) -from .utils import is_momentary_input SENSORS: Final = { ("device", "overtemp"): BlockAttributeDescription( @@ -48,7 +55,7 @@ SENSORS: Final = { ("sensor", "dwIsOpened"): BlockAttributeDescription( name="Door", device_class=DEVICE_CLASS_OPENING, - available=lambda block: cast(bool, block.dwIsOpened != -1), + available=lambda block: cast(int, block.dwIsOpened) != -1, ), ("sensor", "flood"): BlockAttributeDescription( name="Flood", device_class=DEVICE_CLASS_MOISTURE @@ -69,19 +76,19 @@ SENSORS: Final = { name="Input", device_class=DEVICE_CLASS_POWER, default_enabled=False, - removal_condition=is_momentary_input, + removal_condition=is_block_momentary_input, ), ("relay", "input"): BlockAttributeDescription( name="Input", device_class=DEVICE_CLASS_POWER, default_enabled=False, - removal_condition=is_momentary_input, + removal_condition=is_block_momentary_input, ), ("device", "input"): BlockAttributeDescription( name="Input", device_class=DEVICE_CLASS_POWER, default_enabled=False, - removal_condition=is_momentary_input, + removal_condition=is_block_momentary_input, ), ("sensor", "extInput"): BlockAttributeDescription( name="External Input", @@ -112,6 +119,35 @@ REST_SENSORS: Final = { ), } +RPC_SENSORS: Final = { + "input": RpcAttributeDescription( + key="input", + sub_key="state", + name="Input", + device_class=DEVICE_CLASS_POWER, + default_enabled=False, + removal_condition=is_rpc_momentary_input, + ), + "cloud": RpcAttributeDescription( + key="cloud", + sub_key="connected", + name="Cloud", + device_class=DEVICE_CLASS_CONNECTIVITY, + default_enabled=False, + ), + "fwupdate": RpcAttributeDescription( + key="sys", + sub_key="available_updates", + name="Firmware Update", + device_class=DEVICE_CLASS_UPDATE, + default_enabled=False, + extra_state_attributes=lambda status: { + "latest_stable_version": status.get("stable", {"version": ""})["version"], + "beta_version": status.get("beta", {"version": ""})["version"], + }, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -119,29 +155,34 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + return await async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor + ) + if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( hass, config_entry, async_add_entities, SENSORS, - ShellySleepingBinarySensor, + BlockSleepingBinarySensor, ) else: await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor + hass, config_entry, async_add_entities, SENSORS, BlockBinarySensor ) await async_setup_entry_rest( hass, config_entry, async_add_entities, REST_SENSORS, - ShellyRestBinarySensor, + RestBinarySensor, ) -class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): - """Shelly binary sensor entity.""" +class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): + """Represent a block binary sensor entity.""" @property def is_on(self) -> bool: @@ -149,8 +190,8 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): - """Shelly REST binary sensor entity.""" +class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): + """Represent a REST binary sensor entity.""" @property def is_on(self) -> bool: @@ -158,10 +199,17 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class ShellySleepingBinarySensor( - ShellySleepingBlockAttributeEntity, BinarySensorEntity -): - """Represent a shelly sleeping binary sensor.""" +class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): + """Represent a RPC binary sensor entity.""" + + @property + def is_on(self) -> bool: + """Return true if RPC sensor state is on.""" + return bool(self.attribute_value) + + +class BlockSleepingBinarySensor(ShellySleepingBlockAttributeEntity, BinarySensorEntity): + """Represent a block sleeping binary sensor.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index c4ddbc0b0aa..31f99b2b1fb 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -3,26 +3,37 @@ from __future__ import annotations import asyncio import logging -from typing import Any, Dict, Final, cast +from typing import Any, Final import aiohttp import aioshelly +from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import async_timeout import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_UNAUTHORIZED, ) +from homeassistant.core import HomeAssistant 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 +from .utils import ( + get_block_device_name, + get_block_device_sleep_period, + get_coap_context, + get_info_auth, + get_info_gen, + get_model_name, + get_rpc_device_name, +) _LOGGER: Final = logging.getLogger(__name__) @@ -32,34 +43,49 @@ HTTP_CONNECT_ERRORS: Final = (asyncio.TimeoutError, aiohttp.ClientError) async def validate_input( - hass: core.HomeAssistant, host: str, data: dict[str, Any] + hass: HomeAssistant, + host: str, + info: dict[str, Any], + 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. + Data has the keys from HOST_SCHEMA with values provided by the user. """ - - options = aioshelly.ConnectionOptions( - host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD) + options = aioshelly.common.ConnectionOptions( + host, + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), ) - coap_context = await get_coap_context(hass) async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - device = await aioshelly.Device.create( + if get_info_gen(info) == 2: + rpc_device = await RpcDevice.create( + aiohttp_client.async_get_clientsession(hass), + options, + ) + await rpc_device.shutdown() + return { + "title": get_rpc_device_name(rpc_device), + "sleep_period": 0, + "model": rpc_device.model, + "gen": 2, + } + + # Gen1 + coap_context = await get_coap_context(hass) + block_device = await BlockDevice.create( aiohttp_client.async_get_clientsession(hass), coap_context, options, ) - - device.shutdown() - - # Return info that you want to store in the config entry. - return { - "title": device.settings["name"], - "hostname": device.settings["device"]["hostname"], - "sleep_period": get_device_sleep_period(device.settings), - "model": device.settings["device"]["type"], - } + block_device.shutdown() + return { + "title": get_block_device_name(block_device), + "sleep_period": get_block_device_sleep_period(block_device.settings), + "model": block_device.model, + "gen": 1, + } class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -79,23 +105,25 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: host: str = user_input[CONF_HOST] try: - info = await self._async_get_info(host) + self.info = await self._async_get_info(host) except HTTP_CONNECT_ERRORS: errors["base"] = "cannot_connect" - except aioshelly.FirmwareUnsupported: + except aioshelly.exceptions.FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(info["mac"]) + await self.async_set_unique_id(self.info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host - if info["auth"]: + if get_info_auth(self.info): return await self.async_step_credentials() try: - device_info = await validate_input(self.hass, self.host, {}) + device_info = await validate_input( + self.hass, self.host, self.info, {} + ) except HTTP_CONNECT_ERRORS: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except @@ -103,11 +131,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: return self.async_create_entry( - title=device_info["title"] or device_info["hostname"], + title=device_info["title"], data={ **user_input, "sleep_period": device_info["sleep_period"], "model": device_info["model"], + "gen": device_info["gen"], }, ) @@ -122,7 +151,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: try: - device_info = await validate_input(self.hass, self.host, user_input) + device_info = await validate_input( + self.hass, self.host, self.info, user_input + ) except aiohttp.ClientResponseError as error: if error.status == HTTP_UNAUTHORIZED: errors["base"] = "invalid_auth" @@ -135,12 +166,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: return self.async_create_entry( - title=device_info["title"] or device_info["hostname"], + title=device_info["title"], data={ **user_input, CONF_HOST: self.host, "sleep_period": device_info["sleep_period"], "model": device_info["model"], + "gen": device_info["gen"], }, ) else: @@ -162,13 +194,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" try: - self.info = info = await self._async_get_info(discovery_info["host"]) + self.info = await self._async_get_info(discovery_info["host"]) except HTTP_CONNECT_ERRORS: return self.async_abort(reason="cannot_connect") - except aioshelly.FirmwareUnsupported: + except aioshelly.exceptions.FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") - await self.async_set_unique_id(info["mac"]) + await self.async_set_unique_id(self.info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) self.host = discovery_info["host"] @@ -176,11 +208,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "name": discovery_info.get("name", "").split(".")[0] } - if info["auth"]: + if get_info_auth(self.info): return await self.async_step_credentials() try: - self.device_info = await validate_input(self.hass, self.host, {}) + self.device_info = await validate_input(self.hass, self.host, self.info, {}) except HTTP_CONNECT_ERRORS: return self.async_abort(reason="cannot_connect") @@ -193,11 +225,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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"], + title=self.device_info["title"], data={ "host": self.host, "sleep_period": self.device_info["sleep_period"], "model": self.device_info["model"], + "gen": self.device_info["gen"], }, ) @@ -206,9 +239,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", description_placeholders={ - "model": aioshelly.MODEL_NAMES.get( - self.info["type"], self.info["type"] - ), + "model": get_model_name(self.info), "host": self.host, }, errors=errors, @@ -217,10 +248,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 cast( - Dict[str, Any], - await aioshelly.get_info( - aiohttp_client.async_get_clientsession(self.hass), - host, - ), + return await aioshelly.common.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 5646086285d..3c9c24b1f7f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -4,11 +4,12 @@ from __future__ import annotations import re from typing import Final -COAP: Final = "coap" +BLOCK: Final = "block" DATA_CONFIG_ENTRY: Final = "config_entry" DEVICE: Final = "device" DOMAIN: Final = "shelly" REST: Final = "rest" +RPC: Final = "rpc" CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 @@ -44,6 +45,9 @@ SLEEP_PERIOD_MULTIPLIER: Final = 1.2 # Multiplier used to calculate the "update_interval" for non-sleeping devices. UPDATE_PERIOD_MULTIPLIER: Final = 2.2 +# Reconnect interval for GEN2 devices +RPC_RECONNECT_INTERVAL = 60 + # Shelly Air - Maximum work hours before lamp replacement SHAIR_MAX_WORK_HOURS: Final = 9000 @@ -60,18 +64,28 @@ INPUTS_EVENTS_DICT: Final = { # List of battery devices that maintain a permanent WiFi connection BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] +# Button/Click events for Block & RPC devices EVENT_SHELLY_CLICK: Final = "shelly.click" ATTR_CLICK_TYPE: Final = "click_type" ATTR_CHANNEL: Final = "channel" ATTR_DEVICE: Final = "device" +ATTR_GENERATION: Final = "generation" CONF_SUBTYPE: Final = "subtype" BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"} SHBTN_INPUTS_EVENTS_TYPES: Final = {"single", "double", "triple", "long"} -SUPPORTED_INPUTS_EVENTS_TYPES: Final = { +RPC_INPUTS_EVENTS_TYPES: Final = { + "btn_down", + "btn_up", + "single_push", + "double_push", + "long_push", +} + +BLOCK_INPUTS_EVENTS_TYPES: Final = { "single", "double", "triple", @@ -80,9 +94,15 @@ SUPPORTED_INPUTS_EVENTS_TYPES: Final = { "long_single", } -SHIX3_1_INPUTS_EVENTS_TYPES = SUPPORTED_INPUTS_EVENTS_TYPES +SHIX3_1_INPUTS_EVENTS_TYPES = BLOCK_INPUTS_EVENTS_TYPES -INPUTS_EVENTS_SUBTYPES: Final = {"button": 1, "button1": 1, "button2": 2, "button3": 3} +INPUTS_EVENTS_SUBTYPES: Final = { + "button": 1, + "button1": 1, + "button2": 2, + "button3": 3, + "button4": 4, +} SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] @@ -109,3 +129,6 @@ KELVIN_MIN_VALUE_WHITE: Final = 2700 KELVIN_MIN_VALUE_COLOR: Final = 3000 UPTIME_DEVIATION: Final = 5 + +# Max RPC switch/input key instances +MAX_RPC_KEY_INSTANCES = 4 diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 73b8b1baae3..47166ff2dbd 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioshelly import Block +from aioshelly.block_device import Block from homeassistant.components.cover import ( ATTR_POSITION, @@ -18,8 +18,8 @@ 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 . import BlockDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up cover for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] blocks = [block for block in wrapper.device.blocks if block.type == "roller"] if not blocks: @@ -43,7 +43,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): _attr_device_class = DEVICE_CLASS_SHUTTER - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -57,7 +57,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): if self.control_result: return cast(bool, self.control_result["current_pos"] == 0) - return cast(bool, self.block.rollerPos == 0) + return cast(int, self.block.rollerPos) == 0 @property def current_cover_position(self) -> int: @@ -73,7 +73,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): if self.control_result: return cast(bool, self.control_result["state"] == "close") - return cast(bool, self.block.roller == "close") + return self.block.roller == "close" @property def is_opening(self) -> bool: @@ -81,7 +81,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): if self.control_result: return cast(bool, self.control_result["state"] == "open") - return cast(bool, self.block.roller == "open") + return self.block.roller == "open" @property def supported_features(self) -> int: diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index c44dd279230..f5abf76e8f2 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any, Final import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -22,28 +25,52 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType -from . import get_device_wrapper +from . import get_block_device_wrapper, get_rpc_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, + BLOCK_INPUTS_EVENTS_TYPES, CONF_SUBTYPE, DOMAIN, EVENT_SHELLY_CLICK, INPUTS_EVENTS_SUBTYPES, - SHBTN_INPUTS_EVENTS_TYPES, + RPC_INPUTS_EVENTS_TYPES, SHBTN_MODELS, - SUPPORTED_INPUTS_EVENTS_TYPES, ) -from .utils import get_input_triggers +from .utils import ( + get_block_input_triggers, + get_rpc_input_triggers, + get_shbtn_input_triggers, +) TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_TYPE): vol.In(SUPPORTED_INPUTS_EVENTS_TYPES), + vol.Required(CONF_TYPE): vol.In( + RPC_INPUTS_EVENTS_TYPES | BLOCK_INPUTS_EVENTS_TYPES + ), vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), } ) +def append_input_triggers( + triggers: list[dict[str, Any]], + input_triggers: list[tuple[str, str]], + device_id: str, +) -> None: + """Add trigger to triggers list.""" + for trigger, subtype in input_triggers: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + async def async_validate_trigger_config( hass: HomeAssistant, config: dict[str, Any] ) -> dict[str, Any]: @@ -51,17 +78,29 @@ async def async_validate_trigger_config( config = TRIGGER_SCHEMA(config) # if device is available verify parameters against device capabilities - wrapper = get_device_wrapper(hass, config[CONF_DEVICE_ID]) - if not wrapper or not wrapper.device.initialized: - return config - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - for block in wrapper.device.blocks: - input_triggers = get_input_triggers(wrapper.device, block) + if config[CONF_TYPE] in RPC_INPUTS_EVENTS_TYPES: + rpc_wrapper = get_rpc_device_wrapper(hass, config[CONF_DEVICE_ID]) + if not rpc_wrapper or not rpc_wrapper.device.initialized: + return config + + input_triggers = get_rpc_input_triggers(rpc_wrapper.device) if trigger in input_triggers: return config + elif config[CONF_TYPE] in BLOCK_INPUTS_EVENTS_TYPES: + block_wrapper = get_block_device_wrapper(hass, config[CONF_DEVICE_ID]) + if not block_wrapper or not block_wrapper.device.initialized: + return config + + assert block_wrapper.device.blocks + + for block in block_wrapper.device.blocks: + input_triggers = get_block_input_triggers(block_wrapper.device, block) + if trigger in input_triggers: + return config + raise InvalidDeviceAutomationConfig( f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" ) @@ -71,47 +110,35 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device triggers for Shelly devices.""" - triggers = [] + triggers: list[dict[str, Any]] = [] - wrapper = get_device_wrapper(hass, device_id) - if not wrapper: - raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") - - if wrapper.model in SHBTN_MODELS: - for trigger in SHBTN_INPUTS_EVENTS_TYPES: - triggers.append( - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: trigger, - CONF_SUBTYPE: "button", - } - ) + if rpc_wrapper := get_rpc_device_wrapper(hass, device_id): + input_triggers = get_rpc_input_triggers(rpc_wrapper.device) + append_input_triggers(triggers, input_triggers, device_id) return triggers - for block in wrapper.device.blocks: - input_triggers = get_input_triggers(wrapper.device, block) + if block_wrapper := get_block_device_wrapper(hass, device_id): + if block_wrapper.model in SHBTN_MODELS: + input_triggers = get_shbtn_input_triggers() + append_input_triggers(triggers, input_triggers, device_id) + return triggers - for trigger, subtype in input_triggers: - triggers.append( - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) + assert block_wrapper.device.blocks - return triggers + for block in block_wrapper.device.blocks: + input_triggers = get_block_input_triggers(block_wrapper.device, block) + append_input_triggers(triggers, input_triggers, device_id) + + return triggers + + raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" event_config = { @@ -123,6 +150,7 @@ async def async_attach_trigger( ATTR_CLICK_TYPE: config[CONF_TYPE], }, } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) return await event_trigger.async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 743dd07414e..0fe25884f00 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Callable, Final, cast +from typing import Any, Final, cast -import aioshelly +from aioshelly.block_device import Block import async_timeout from homeassistant.components.sensor import ATTR_STATE_CLASS @@ -23,9 +24,21 @@ 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 +from . import BlockDeviceWrapper, RpcDeviceWrapper, ShellyDeviceRestWrapper +from .const import ( + AIOSHELLY_DEVICE_TIMEOUT_SEC, + BLOCK, + DATA_CONFIG_ENTRY, + DOMAIN, + REST, + RPC, +) +from .utils import ( + async_remove_shelly_entity, + get_block_entity_name, + get_rpc_entity_name, + get_rpc_key_instances, +) _LOGGER: Final = logging.getLogger(__name__) @@ -38,9 +51,9 @@ async def async_setup_entry_attribute_entities( sensor_class: Callable, ) -> None: """Set up entities for attributes.""" - wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id - ][COAP] + ][BLOCK] if wrapper.device.initialized: await async_setup_block_attribute_entities( @@ -55,13 +68,15 @@ async def async_setup_entry_attribute_entities( async def async_setup_block_attribute_entities( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, sensors: dict[tuple[str, str], BlockAttributeDescription], sensor_class: Callable, ) -> None: """Set up entities for block attributes.""" blocks = [] + assert wrapper.device.blocks + for block in wrapper.device.blocks: for sensor_id in block.sensor_ids: description = sensors.get((block.type, sensor_id)) @@ -97,7 +112,7 @@ async def async_restore_block_attribute_entities( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, sensors: dict[tuple[str, str], BlockAttributeDescription], sensor_class: Callable, ) -> None: @@ -133,6 +148,49 @@ async def async_restore_block_attribute_entities( async_add_entities(entities) +async def async_setup_entry_rpc( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: dict[str, RpcAttributeDescription], + sensor_class: Callable, +) -> None: + """Set up entities for REST sensors.""" + wrapper: RpcDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry.entry_id + ][RPC] + + entities = [] + for sensor_id in sensors: + description = sensors[sensor_id] + key_instances = get_rpc_key_instances(wrapper.device.status, description.key) + + for key in key_instances: + # Filter non-existing sensors + if description.sub_key not in wrapper.device.status[key]: + continue + + # Filter and remove entities that according to settings should not create an entity + if description.removal_condition and description.removal_condition( + wrapper.device.config, key + ): + domain = sensor_class.__module__.split(".")[-1] + unique_id = f"{wrapper.mac}-{key}-{sensor_id}" + await async_remove_shelly_entity(hass, domain, unique_id) + else: + entities.append((key, sensor_id, description)) + + if not entities: + return + + async_add_entities( + [ + sensor_class(wrapper, key, sensor_id, description) + for key, sensor_id, description in entities + ] + ) + + async def async_setup_entry_rest( hass: HomeAssistant, config_entry: ConfigEntry, @@ -175,10 +233,28 @@ class BlockAttributeDescription: device_class: str | None = None state_class: str | None = None default_enabled: bool = True - available: Callable[[aioshelly.Block], bool] | None = None + available: Callable[[Block], bool] | None = None # 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 + removal_condition: Callable[[dict, Block], bool] | None = None + extra_state_attributes: Callable[[Block], dict | None] | None = None + + +@dataclass +class RpcAttributeDescription: + """Class to describe a RPC sensor.""" + + key: str + sub_key: str + name: str + icon: str | None = None + unit: str | None = None + value: Callable[[Any, Any], Any] | None = None + device_class: str | None = None + state_class: str | None = None + default_enabled: bool = True + available: Callable[[dict], bool] | None = None + removal_condition: Callable[[dict, str], bool] | None = None + extra_state_attributes: Callable[[dict], dict | None] | None = None @dataclass @@ -196,13 +272,13 @@ class RestAttributeDescription: class ShellyBlockEntity(entity.Entity): - """Helper class to represent a block.""" + """Helper class to represent a block entity.""" - def __init__(self, wrapper: ShellyDeviceWrapper, block: aioshelly.Block) -> None: + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize Shelly entity.""" self.wrapper = wrapper self.block = block - self._name = get_entity_name(wrapper.device, block) + self._name = get_block_entity_name(wrapper.device, block) @property def name(self) -> str: @@ -261,13 +337,68 @@ class ShellyBlockEntity(entity.Entity): return None +class ShellyRpcEntity(entity.Entity): + """Helper class to represent a rpc entity.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str) -> None: + """Initialize Shelly entity.""" + self.wrapper = wrapper + self.key = key + self._attr_should_poll = False + self._attr_device_info = { + "connections": {(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + } + self._attr_unique_id = f"{wrapper.mac}-{key}" + self._attr_name = get_rpc_entity_name(wrapper.device, key) + + @property + def available(self) -> bool: + """Available.""" + return self.wrapper.device.connected + + 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) -> None: + """Update entity with latest info.""" + await self.wrapper.async_request_refresh() + + @callback + def _update_callback(self) -> None: + """Handle device update.""" + self.async_write_ha_state() + + async def call_rpc(self, method: str, params: Any) -> Any: + """Call RPC method.""" + _LOGGER.debug( + "Call RPC for entity %s, method: %s, params: %s", + self.name, + method, + params, + ) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + return await self.wrapper.device.call_rpc(method, params) + except asyncio.TimeoutError as err: + _LOGGER.error( + "Call RPC for entity %s failed, method: %s, params: %s, error: %s", + self.name, + method, + params, + repr(err), + ) + self.wrapper.last_update_success = False + return None + + class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): """Helper class to represent a block attribute.""" def __init__( self, - wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, + wrapper: BlockDeviceWrapper, + block: Block, attribute: str, description: BlockAttributeDescription, ) -> None: @@ -283,7 +414,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): self._unit: None | str | Callable[[dict], str] = unit self._unique_id: str = f"{super().unique_id}-{self.attribute}" - self._name = get_entity_name(wrapper.device, block, self.description.name) + self._name = get_block_entity_name(wrapper.device, block, self.description.name) @property def unique_id(self) -> str: @@ -344,7 +475,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, attribute: str, description: RestAttributeDescription, ) -> None: @@ -353,7 +484,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): self.wrapper = wrapper self.attribute = attribute self.description = description - self._name = get_entity_name(wrapper.device, None, self.description.name) + self._name = get_block_entity_name(wrapper.device, None, self.description.name) self._last_value = None @property @@ -411,14 +542,72 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return self.description.extra_state_attributes(self.wrapper.device.status) +class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): + """Helper class to represent a rpc attribute.""" + + def __init__( + self, + wrapper: RpcDeviceWrapper, + key: str, + attribute: str, + description: RpcAttributeDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(wrapper, key) + self.sub_key = description.sub_key + self.attribute = attribute + self.description = description + + self._attr_unique_id = f"{super().unique_id}-{attribute}" + self._attr_name = get_rpc_entity_name(wrapper.device, key, description.name) + self._attr_entity_registry_enabled_default = description.default_enabled + self._attr_device_class = description.device_class + self._attr_icon = description.icon + self._last_value = None + + @property + def attribute_value(self) -> StateType: + """Value of sensor.""" + if callable(self.description.value): + self._last_value = self.description.value( + self.wrapper.device.status[self.key][self.sub_key], self._last_value + ) + else: + self._last_value = self.wrapper.device.status[self.key][self.sub_key] + + return self._last_value + + @property + def available(self) -> bool: + """Available.""" + available = super().available + + if not available or not self.description.available: + return available + + return self.description.available( + self.wrapper.device.status[self.key][self.sub_key] + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.description.extra_state_attributes is None: + return None + + return self.description.extra_state_attributes( + self.wrapper.device.status[self.key][self.sub_key] + ) + + class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity): """Represent a shelly sleeping block attribute entity.""" # pylint: disable=super-init-not-called def __init__( self, - wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, + wrapper: BlockDeviceWrapper, + block: Block | None, attribute: str, description: BlockAttributeDescription, entry: entity_registry.RegistryEntry | None = None, @@ -429,7 +618,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self.last_state: StateType = None self.wrapper = wrapper self.attribute = attribute - self.block = block + self.block: Block | None = block # type: ignore[assignment] self.description = description self._unit = self.description.unit @@ -438,7 +627,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self._unit = self._unit(block.info(attribute)) self._unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}" - self._name = get_entity_name( + self._name = get_block_entity_name( self.wrapper.device, block, self.description.name ) elif entry is not None: @@ -468,6 +657,8 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti _, entity_block, entity_sensor = self.unique_id.split("-") + assert self.wrapper.device.blocks + for block in self.wrapper.device.blocks: if block.description != entity_block: continue diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 86624410708..cd034c1e7e5 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -5,7 +5,7 @@ import asyncio import logging from typing import Any, Final, cast -from aioshelly import Block +from aioshelly.block_device import Block import async_timeout from homeassistant.components.light import ( @@ -33,10 +33,10 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) -from . import ShellyDeviceWrapper +from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, - COAP, + BLOCK, DATA_CONFIG_ENTRY, DOMAIN, FIRMWARE_PATTERN, @@ -46,11 +46,18 @@ from .const import ( LIGHT_TRANSITION_MIN_FIRMWARE_DATE, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, + RPC, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) -from .entity import ShellyBlockEntity -from .utils import async_remove_shelly_entity +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_block_channel_type_light, + is_rpc_channel_type_light, +) _LOGGER: Final = logging.getLogger(__name__) @@ -61,33 +68,70 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for block device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] blocks = [] + assert wrapper.device.blocks for block in wrapper.device.blocks: if block.type == "light": blocks.append(block) elif block.type == "relay": - appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( - "appliance_type" - ) - if appliance_type and appliance_type.lower() == "light": - blocks.append(block) - unique_id = ( - f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' - ) - await async_remove_shelly_entity(hass, "switch", unique_id) + if not is_block_channel_type_light( + wrapper.device.settings, int(block.channel) + ): + continue + + blocks.append(block) + assert wrapper.device.shelly + unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + await async_remove_shelly_entity(hass, "switch", unique_id) if not blocks: return - async_add_entities(ShellyLight(wrapper, block) for block in blocks) + async_add_entities(BlockShellyLight(wrapper, block) for block in blocks) -class ShellyLight(ShellyBlockEntity, LightEntity): - """Switch that controls a relay block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] + switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch") - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + switch_ids = [] + for id_ in switch_key_ids: + if not is_rpc_channel_type_light(wrapper.device.config, id_): + continue + + switch_ids.append(id_) + unique_id = f"{wrapper.mac}-switch:{id_}" + await async_remove_shelly_entity(hass, "switch", unique_id) + + if not switch_ids: + return + + async_add_entities(RpcShellyLight(wrapper, id_) for id_ in switch_ids) + + +class BlockShellyLight(ShellyBlockEntity, LightEntity): + """Entity that controls a light on block based Shelly devices.""" + + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -117,7 +161,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self._supported_features |= SUPPORT_EFFECT if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw")) + match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw", "")) if ( match is not None and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE @@ -369,3 +413,25 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.control_result = None self.mode_result = None super()._update_callback() + + +class RpcShellyLight(ShellyRpcEntity, LightEntity): + """Entity that controls a light on RPC based Shelly devices.""" + + def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: + """Initialize light.""" + super().__init__(wrapper, f"switch:{id_}") + self._id = id_ + + @property + def is_on(self) -> bool: + """If light is on.""" + return bool(self.wrapper.device.status[self.key]["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index deac3b5c05b..d4278e3e98e 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -1,21 +1,23 @@ """Describe Shelly logbook events.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import EventType -from . import get_device_wrapper +from . import get_block_device_wrapper, get_rpc_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, ATTR_DEVICE, + BLOCK_INPUTS_EVENTS_TYPES, DOMAIN, EVENT_SHELLY_CLICK, + RPC_INPUTS_EVENTS_TYPES, ) -from .utils import get_device_name +from .utils import get_block_device_name, get_rpc_entity_name @callback @@ -27,19 +29,27 @@ def async_describe_events( @callback 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 and wrapper.device.initialized: - device_name = get_device_name(wrapper.device) - else: - device_name = event.data[ATTR_DEVICE] - - channel = event.data[ATTR_CHANNEL] + """Describe shelly.click logbook event (block device).""" + device_id = event.data[ATTR_DEVICE_ID] click_type = event.data[ATTR_CLICK_TYPE] + channel = event.data[ATTR_CHANNEL] + input_name = f"{event.data[ATTR_DEVICE]} channel {channel}" + + if click_type in RPC_INPUTS_EVENTS_TYPES: + rpc_wrapper = get_rpc_device_wrapper(hass, device_id) + if rpc_wrapper and rpc_wrapper.device.initialized: + key = f"input:{channel-1}" + input_name = get_rpc_entity_name(rpc_wrapper.device, key) + + elif click_type in BLOCK_INPUTS_EVENTS_TYPES: + block_wrapper = get_block_device_wrapper(hass, device_id) + if block_wrapper and block_wrapper.device.initialized: + device_name = get_block_device_name(block_wrapper.device) + input_name = f"{device_name} channel {channel}" return { "name": "Shelly", - "message": f"'{click_type}' click event for {device_name} channel {channel} was fired.", + "message": f"'{click_type}' click event for {input_name} Input was fired.", } async_describe_event(DOMAIN, EVENT_SHELLY_CLICK, async_describe_shelly_click_event) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ab87c4cef38..09a046ee78d 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==0.6.4"], + "requirements": ["aioshelly==1.0.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index d8d530ed94c..9ee0712aaef 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,13 +26,16 @@ from .const import SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, + RpcAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, + async_setup_entry_rpc, ) -from .utils import get_device_uptime, temperature_unit +from .utils import get_device_entry_gen, get_device_uptime, temperature_unit SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( @@ -40,7 +44,7 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_BATTERY, state_class=sensor.STATE_CLASS_MEASUREMENT, removal_condition=lambda settings, _: settings.get("external_power") == 1, - available=lambda block: cast(bool, block.battery != -1), + available=lambda block: cast(int, block.battery) != -1, ), ("device", "deviceTemp"): BlockAttributeDescription( name="Device Temperature", @@ -79,6 +83,14 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_POWER, state_class=sensor.STATE_CLASS_MEASUREMENT, ), + ("device", "voltage"): BlockAttributeDescription( + name="Voltage", + unit=ELECTRIC_POTENTIAL_VOLT, + value=lambda value: round(value, 1), + device_class=sensor.DEVICE_CLASS_VOLTAGE, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), ("emeter", "voltage"): BlockAttributeDescription( name="Voltage", unit=ELECTRIC_POTENTIAL_VOLT, @@ -162,7 +174,7 @@ SENSORS: Final = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_TEMPERATURE, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: cast(bool, block.extTemp != 999), + available=lambda block: cast(int, block.extTemp) != 999, ), ("sensor", "humidity"): BlockAttributeDescription( name="Humidity", @@ -170,14 +182,14 @@ SENSORS: Final = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_HUMIDITY, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: cast(bool, block.extTemp != 999), + available=lambda block: cast(int, block.extTemp) != 999, ), ("sensor", "luminosity"): BlockAttributeDescription( name="Luminosity", unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: cast(bool, block.luminosity != -1), + available=lambda block: cast(int, block.luminosity) != -1, ), ("sensor", "tilt"): BlockAttributeDescription( name="Tilt", @@ -191,7 +203,7 @@ SENSORS: Final = { icon="mdi:progress-wrench", value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1), extra_state_attributes=lambda block: { - "Operational hours": round(block.totalWorkTime / 3600, 1) + "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, ), ("adc", "adc"): BlockAttributeDescription( @@ -219,6 +231,65 @@ REST_SENSORS: Final = { default_enabled=False, ), "uptime": RestAttributeDescription( + name="Uptime", + value=lambda status, last: get_device_uptime(status["uptime"], last), + device_class=sensor.DEVICE_CLASS_TIMESTAMP, + default_enabled=False, + ), +} + + +RPC_SENSORS: Final = { + "power": RpcAttributeDescription( + key="switch", + sub_key="apower", + name="Power", + unit=POWER_WATT, + value=lambda status, _: round(float(status), 1), + device_class=sensor.DEVICE_CLASS_POWER, + state_class=sensor.STATE_CLASS_MEASUREMENT, + ), + "voltage": RpcAttributeDescription( + key="switch", + sub_key="voltage", + name="Voltage", + unit=ELECTRIC_POTENTIAL_VOLT, + value=lambda status, _: round(float(status), 1), + device_class=sensor.DEVICE_CLASS_VOLTAGE, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), + "energy": RpcAttributeDescription( + key="switch", + sub_key="aenergy", + name="Energy", + unit=ENERGY_KILO_WATT_HOUR, + value=lambda status, _: round(status["total"] / 1000, 2), + device_class=sensor.DEVICE_CLASS_ENERGY, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, + ), + "temperature": RpcAttributeDescription( + key="switch", + sub_key="temperature", + name="Temperature", + unit=TEMP_CELSIUS, + value=lambda status, _: round(status["tC"], 1), + device_class=sensor.DEVICE_CLASS_TEMPERATURE, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), + "rssi": RpcAttributeDescription( + key="wifi", + sub_key="rssi", + name="RSSI", + unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=sensor.DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=sensor.STATE_CLASS_MEASUREMENT, + default_enabled=False, + ), + "uptime": RpcAttributeDescription( + key="sys", + sub_key="uptime", name="Uptime", value=get_device_uptime, device_class=sensor.DEVICE_CLASS_TIMESTAMP, @@ -233,21 +304,26 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + return await async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor + ) + if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellySleepingSensor + hass, config_entry, async_add_entities, SENSORS, BlockSleepingSensor ) else: await async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, SENSORS, ShellySensor + hass, config_entry, async_add_entities, SENSORS, BlockSensor ) await async_setup_entry_rest( - hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor + hass, config_entry, async_add_entities, REST_SENSORS, RestSensor ) -class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): - """Represent a shelly sensor.""" +class BlockSensor(ShellyBlockAttributeEntity, SensorEntity): + """Represent a block sensor.""" @property def native_value(self) -> StateType: @@ -265,8 +341,8 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): return cast(str, self._unit) -class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): - """Represent a shelly REST sensor.""" +class RestSensor(ShellyRestAttributeEntity, SensorEntity): + """Represent a REST sensor.""" @property def native_value(self) -> StateType: @@ -284,8 +360,27 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): return self.description.unit -class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): - """Represent a shelly sleeping sensor.""" +class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): + """Represent a RPC sensor.""" + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.attribute_value + + @property + def state_class(self) -> str | None: + """State class of sensor.""" + return self.description.state_class + + @property + def native_unit_of_measurement(self) -> str | None: + """Return unit of sensor.""" + return self.description.unit + + +class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): + """Represent a block sleeping sensor.""" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 85a1fa87d0c..43cae79f94a 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -33,7 +33,8 @@ "button": "Button", "button1": "First button", "button2": "Second button", - "button3": "Third button" + "button3": "Third button", + "button4": "Fourth button" }, "trigger_type": { "single": "{subtype} single clicked", @@ -41,7 +42,12 @@ "triple": "{subtype} triple clicked", "long": " {subtype} long clicked", "single_long": "{subtype} single clicked and then long clicked", - "long_single": "{subtype} long clicked and then single clicked" + "long_single": "{subtype} long clicked and then single clicked", + "btn_down": "{subtype} button down", + "btn_up": "{subtype} button up", + "single_push": "{subtype} single push", + "double_push": "{subtype} double push", + "long_push": " {subtype} long push" } } } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 3e35ba878e4..0291258b511 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -3,17 +3,23 @@ from __future__ import annotations from typing import Any, cast -from aioshelly import Block +from aioshelly.block_device import Block from homeassistant.components.switch import SwitchEntity 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 -from .utils import async_remove_shelly_entity +from . import BlockDeviceWrapper, RpcDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_block_channel_type_light, + is_rpc_channel_type_light, +) async def async_setup_entry( @@ -22,7 +28,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for block device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] # In roller mode the relay blocks exist but do not contain required info if ( @@ -32,32 +50,51 @@ async def async_setup_entry( return relay_blocks = [] + assert wrapper.device.blocks for block in wrapper.device.blocks: - if block.type == "relay": - appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( - "appliance_type" - ) - if not appliance_type or appliance_type.lower() != "light": - relay_blocks.append(block) - unique_id = ( - f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' - ) - await async_remove_shelly_entity( - hass, - "light", - unique_id, - ) + if block.type != "relay" or is_block_channel_type_light( + wrapper.device.settings, int(block.channel) + ): + continue + + relay_blocks.append(block) + unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + await async_remove_shelly_entity(hass, "light", unique_id) if not relay_blocks: return - async_add_entities(RelaySwitch(wrapper, block) for block in relay_blocks) + async_add_entities(BlockRelaySwitch(wrapper, block) for block in relay_blocks) -class RelaySwitch(ShellyBlockEntity, SwitchEntity): - """Switch that controls a relay block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] + switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch") - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + switch_ids = [] + for id_ in switch_key_ids: + if is_rpc_channel_type_light(wrapper.device.config, id_): + continue + + switch_ids.append(id_) + unique_id = f"{wrapper.mac}-switch:{id_}" + await async_remove_shelly_entity(hass, "light", unique_id) + + if not switch_ids: + return + + async_add_entities(RpcRelaySwitch(wrapper, id_) for id_ in switch_ids) + + +class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): + """Entity that controls a relay on Block based Shelly devices.""" + + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize relay switch.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -85,3 +122,25 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity): """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() + + +class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): + """Entity that controls a relay on RPC based Shelly devices.""" + + def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: + """Initialize relay switch.""" + super().__init__(wrapper, f"switch:{id_}") + self._id = id_ + + @property + def is_on(self) -> bool: + """If switch is on.""" + return bool(self.wrapper.device.status[self.key]["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on relay.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off relay.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) diff --git a/homeassistant/components/shelly/translations/ca.json b/homeassistant/components/shelly/translations/ca.json index 13cc79ac3d8..c485d955ff2 100644 --- a/homeassistant/components/shelly/translations/ca.json +++ b/homeassistant/components/shelly/translations/ca.json @@ -33,14 +33,20 @@ "button": "Bot\u00f3", "button1": "Primer bot\u00f3", "button2": "Segon bot\u00f3", - "button3": "Tercer bot\u00f3" + "button3": "Tercer bot\u00f3", + "button4": "Quart bot\u00f3" }, "trigger_type": { + "btn_down": "Bot\u00f3 {subtype} avall", + "btn_up": "Bot\u00f3 {subtype} amunt", "double": "{subtype} clicat dues vegades", + "double_push": "{subtype} clicat dues vegades", "long": "{subtype} clicat durant una estona", + "long_push": "{subtype} clicat durant una estona", "long_single": "{subtype} clicat durant una estona i despr\u00e9s r\u00e0pid", "single": "{subtype} clicat una vegada", "single_long": "{subtype} clicat r\u00e0pid i, despr\u00e9s, durant una estona", + "single_push": "{subtype} clicat una vegada", "triple": "{subtype} clicat tres vegades" } } diff --git a/homeassistant/components/shelly/translations/cs.json b/homeassistant/components/shelly/translations/cs.json index afdfe7c8f56..e3f1215d6f2 100644 --- a/homeassistant/components/shelly/translations/cs.json +++ b/homeassistant/components/shelly/translations/cs.json @@ -33,7 +33,8 @@ "button": "Tla\u010d\u00edtko", "button1": "Prvn\u00ed tla\u010d\u00edtko", "button2": "Druh\u00e9 tla\u010d\u00edtko", - "button3": "T\u0159et\u00ed tla\u010d\u00edtko" + "button3": "T\u0159et\u00ed tla\u010d\u00edtko", + "button4": "\u010ctvrt\u00e9 tla\u010d\u00edtko" }, "trigger_type": { "double": "\"{subtype}\" stisknuto dvakr\u00e1t", diff --git a/homeassistant/components/shelly/translations/de.json b/homeassistant/components/shelly/translations/de.json index 513ff66dff1..3a943507284 100644 --- a/homeassistant/components/shelly/translations/de.json +++ b/homeassistant/components/shelly/translations/de.json @@ -33,14 +33,20 @@ "button": "Taste", "button1": "Erste Taste", "button2": "Zweite Taste", - "button3": "Dritte Taste" + "button3": "Dritte Taste", + "button4": "Vierte Taste" }, "trigger_type": { + "btn_down": "{subtype} Taste nach unten", + "btn_up": "{subtype} Taste nach oben", "double": "{subtype} zweifach bet\u00e4tigt", + "double_push": "{subtype} Doppelter Push", "long": "{subtype} gehalten", + "long_push": "{subtype} langer Push", "long_single": "{subtype} gehalten und dann einfach bet\u00e4tigt", "single": "{subtype} einfach bet\u00e4tigt", "single_long": "{subtype} einfach bet\u00e4tigt und dann gehalten", + "single_push": "{subtype} einzelner Push", "triple": "{subtype} dreifach bet\u00e4tigt" } } diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json index b60d9dfbe3e..b48eb630024 100644 --- a/homeassistant/components/shelly/translations/en.json +++ b/homeassistant/components/shelly/translations/en.json @@ -33,14 +33,20 @@ "button": "Button", "button1": "First button", "button2": "Second button", - "button3": "Third button" + "button3": "Third button", + "button4": "Fourth button" }, "trigger_type": { + "btn_down": "{subtype} button down", + "btn_up": "{subtype} button up", "double": "{subtype} double clicked", + "double_push": "{subtype} double push", "long": " {subtype} long clicked", + "long_push": " {subtype} long push", "long_single": "{subtype} long clicked and then single clicked", "single": "{subtype} single clicked", "single_long": "{subtype} single clicked and then long clicked", + "single_push": "{subtype} single push", "triple": "{subtype} triple clicked" } } diff --git a/homeassistant/components/shelly/translations/es.json b/homeassistant/components/shelly/translations/es.json index 09cc3f51378..6f5c86417d4 100644 --- a/homeassistant/components/shelly/translations/es.json +++ b/homeassistant/components/shelly/translations/es.json @@ -33,14 +33,20 @@ "button": "Bot\u00f3n", "button1": "Primer bot\u00f3n", "button2": "Segundo bot\u00f3n", - "button3": "Tercer bot\u00f3n" + "button3": "Tercer bot\u00f3n", + "button4": "Cuarto bot\u00f3n" }, "trigger_type": { + "btn_down": "Bot\u00f3n {subtype} pulsado", + "btn_up": "Bot\u00f3n {subtype} soltado", "double": "Pulsaci\u00f3n doble de {subtype}", + "double_push": "Pulsaci\u00f3n doble de {subtype}", "long": "Pulsaci\u00f3n larga de {subtype}", + "long_push": "Pulsaci\u00f3n larga de {subtype}", "long_single": "Pulsaci\u00f3n larga de {subtype} seguida de una pulsaci\u00f3n simple", "single": "Pulsaci\u00f3n simple de {subtype}", "single_long": "Pulsaci\u00f3n simple de {subtype} seguida de una pulsaci\u00f3n larga", + "single_push": "Pulsaci\u00f3n simple de {subtype}", "triple": "Pulsaci\u00f3n triple de {subtype}" } } diff --git a/homeassistant/components/shelly/translations/et.json b/homeassistant/components/shelly/translations/et.json index 7059ce6b3d3..7db0eaad4ac 100644 --- a/homeassistant/components/shelly/translations/et.json +++ b/homeassistant/components/shelly/translations/et.json @@ -33,14 +33,20 @@ "button": "Nupp", "button1": "Esimene nupp", "button2": "Teine nupp", - "button3": "Kolmas nupp" + "button3": "Kolmas nupp", + "button4": "Neljas nupp" }, "trigger_type": { + "btn_down": "{subtype} nupp vajutatud", + "btn_up": "{subtype} nupp vabastatud", "double": "Nuppu {subtype} topeltkl\u00f5psati", + "double_push": "{subtype} topeltkl\u00f5ps", "long": "Nuppu \"{subtype}\" hoiti all", + "long_push": "{subtype} pikk vajutus", "long_single": "Nuppu {subtype} hoiti all ja seej\u00e4rel kl\u00f5psati", "single": "Nuppu {subtype} kl\u00f5psati", "single_long": "Nuppu {subtype} kl\u00f5psati \u00fcks kord ja seej\u00e4rel hoiti all", + "single_push": "{subtype} l\u00fchike vajutus", "triple": "Nuppu {subtype} kl\u00f5psati kolm korda" } } diff --git a/homeassistant/components/shelly/translations/he.json b/homeassistant/components/shelly/translations/he.json index 44d5897f85d..5eb76e4b55e 100644 --- a/homeassistant/components/shelly/translations/he.json +++ b/homeassistant/components/shelly/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", + "unsupported_firmware": "\u05d4\u05d4\u05ea\u05e7\u05df \u05de\u05e9\u05ea\u05de\u05e9 \u05d1\u05d2\u05d9\u05e8\u05e1\u05ea \u05e7\u05d5\u05e9\u05d7\u05d4 \u05e9\u05d0\u05d9\u05e0\u05d4 \u05e0\u05ea\u05de\u05db\u05ea." }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -11,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} \u05d1-{host}? \n\n\u05d9\u05e9 \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05d4\u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05dc\u05e4\u05e0\u05d9 \u05e9\u05de\u05de\u05e9\u05d9\u05db\u05d9\u05dd \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4.\n\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05e9\u05d0\u05d9\u05e0\u05dd \u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05d9\u05ea\u05d5\u05d5\u05e1\u05e4\u05d5 \u05db\u05d0\u05e9\u05e8 \u05d4\u05d4\u05ea\u05e7\u05df \u05d9\u05ea\u05e2\u05d5\u05e8\u05e8, \u05db\u05e2\u05ea \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05d7\u05db\u05d5\u05ea \u05dc\u05e2\u05d3\u05db\u05d5\u05df \u05d4\u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d4\u05d1\u05d0 \u05de\u05d4\u05d4\u05ea\u05e7\u05df." + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} \u05d1-{host}? \n\n\u05d9\u05e9 \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05d4\u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05dc\u05e4\u05e0\u05d9 \u05e9\u05de\u05de\u05e9\u05d9\u05db\u05d9\u05dd \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4.\n\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4 \u05e9\u05d0\u05d9\u05e0\u05dd \u05de\u05d5\u05d2\u05e0\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d4 \u05d9\u05ea\u05d5\u05d5\u05e1\u05e4\u05d5 \u05db\u05d0\u05e9\u05e8 \u05d4\u05d4\u05ea\u05e7\u05df \u05d9\u05ea\u05e2\u05d5\u05e8\u05e8, \u05db\u05e2\u05ea \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05d7\u05db\u05d5\u05ea \u05dc\u05e2\u05d3\u05db\u05d5\u05df \u05d4\u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d4\u05d1\u05d0 \u05de\u05d4\u05d4\u05ea\u05e7\u05df." }, "credentials": { "data": { @@ -22,8 +23,31 @@ "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" - } + }, + "description": "\u05dc\u05e4\u05e0\u05d9 \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4, \u05d9\u05e9 \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d5\u05dc\u05dc\u05d4, \u05db\u05e2\u05ea \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e2\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc\u05d9\u05d5." } } + }, + "device_automation": { + "trigger_subtype": { + "button": "\u05dc\u05d7\u05e6\u05df", + "button1": "\u05dc\u05d7\u05e6\u05df \u05e8\u05d0\u05e9\u05d5\u05df", + "button2": "\u05dc\u05d7\u05e6\u05df \u05e9\u05e0\u05d9", + "button3": "\u05dc\u05d7\u05e6\u05df \u05e9\u05dc\u05d9\u05e9\u05d9", + "button4": "\u05dc\u05d7\u05e6\u05df \u05e8\u05d1\u05d9\u05e2\u05d9" + }, + "trigger_type": { + "btn_down": "{subtype} \u05dc\u05d7\u05e6\u05df \u05de\u05d8\u05d4", + "btn_up": "{subtype} \u05dc\u05d7\u05e6\u05df\u05df \u05de\u05e2\u05dc\u05d4", + "double": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05db\u05e4\u05d5\u05dc\u05d4", + "double_push": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05db\u05e4\u05d5\u05dc\u05d4", + "long": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "long_push": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "long_single": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea \u05d5\u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea", + "single": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea", + "single_long": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea \u05d5\u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05de\u05d5\u05e9\u05db\u05ea", + "single_push": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea", + "triple": "{subtype} \u05dc\u05d7\u05d9\u05e6\u05d4 \u05de\u05e9\u05d5\u05dc\u05e9\u05ea" + } } } \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/hu.json b/homeassistant/components/shelly/translations/hu.json index 9388e26515a..bfaf591d7c2 100644 --- a/homeassistant/components/shelly/translations/hu.json +++ b/homeassistant/components/shelly/translations/hu.json @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "confirm_discovery": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {model} a(z) {host} c\u00edmen? \n\n A jelsz\u00f3val v\u00e9dett akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, miel\u0151tt folytatn\u00e1 a be\u00e1ll\u00edt\u00e1st.\n Az elemmel m\u0171k\u00f6d\u0151, jelsz\u00f3val nem v\u00e9dett eszk\u00f6z\u00f6k hozz\u00e1ad\u00e1sra ker\u00fclnek, amikor az eszk\u00f6z fel\u00e9bred, most manu\u00e1lisan \u00e9bresztheti fel az eszk\u00f6zt egy rajta l\u00e9v\u0151 gombbal, vagy v\u00e1rhat a k\u00f6vetkez\u0151 adatfriss\u00edt\u00e9sre." + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani {model}-t {host} c\u00edmen? \n\nA jelsz\u00f3val v\u00e9dett akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, miel\u0151tt folytatn\u00e1 a be\u00e1ll\u00edt\u00e1st.\nAz elemmel m\u0171k\u00f6d\u0151, jelsz\u00f3val nem v\u00e9dett eszk\u00f6z\u00f6k hozz\u00e1ad\u00e1sra ker\u00fclnek, amikor az eszk\u00f6z fel\u00e9bred, most manu\u00e1lisan \u00e9bresztheti fel az eszk\u00f6zt egy rajta l\u00e9v\u0151 gombbal, vagy v\u00e1rhat a k\u00f6vetkez\u0151 adatfriss\u00edt\u00e9sre." }, "credentials": { "data": { @@ -22,7 +22,7 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "A be\u00e1ll\u00edt\u00e1s el\u0151tt az akkumul\u00e1toros eszk\u00f6z\u00f6ket fel kell \u00e9breszteni, most egy rajta l\u00e9v\u0151 gombbal fel\u00e9bresztheted az eszk\u00f6zt." } @@ -33,14 +33,20 @@ "button": "Gomb", "button1": "Els\u0151 gomb", "button2": "M\u00e1sodik gomb", - "button3": "Harmadik gomb" + "button3": "Harmadik gomb", + "button4": "Negyedik gomb" }, "trigger_type": { + "btn_down": "{subtype} gomb lenyomva", + "btn_up": "{subtype} gomb elengedve", "double": "{subtype} dupla kattint\u00e1s", + "double_push": "{subtype} dupla lenyom\u00e1s", "long": "{subtype} hosszan nyomva", + "long_push": "{subtype} hosszan lenyomva", "long_single": "{subtype} hosszan nyomva, majd egy kattint\u00e1s", "single": "{subtype} egy kattint\u00e1s", "single_long": "{subtype} egy kattint\u00e1s, majd hosszan nyomva", + "single_push": "{subtype} egy lenyom\u00e1s", "triple": "{subtype} tripla kattint\u00e1s" } } diff --git a/homeassistant/components/shelly/translations/id.json b/homeassistant/components/shelly/translations/id.json index 606ee473805..2f385796fd1 100644 --- a/homeassistant/components/shelly/translations/id.json +++ b/homeassistant/components/shelly/translations/id.json @@ -38,9 +38,11 @@ "trigger_type": { "double": "{subtype} diklik dua kali", "long": "{subtype} diklik lama", + "long_push": "Push lama {subtype}", "long_single": "{subtype} diklik lama kemudian diklik sekali", "single": "{subtype} diklik sekali", "single_long": "{subtype} diklik sekali kemudian diklik lama", + "single_push": "Push tunggal {subtype}", "triple": "{subtype} diklik tiga kali" } } diff --git a/homeassistant/components/shelly/translations/it.json b/homeassistant/components/shelly/translations/it.json index 051cf88dc38..c004141cac4 100644 --- a/homeassistant/components/shelly/translations/it.json +++ b/homeassistant/components/shelly/translations/it.json @@ -33,14 +33,20 @@ "button": "Pulsante", "button1": "Primo pulsante", "button2": "Secondo pulsante", - "button3": "Terzo pulsante" + "button3": "Terzo pulsante", + "button4": "Quarto pulsante" }, "trigger_type": { + "btn_down": "{subtype} pulsante in gi\u00f9", + "btn_up": "{subtype} pulsante in su", "double": "{subtype} premuto due volte", + "double_push": "{subtype} doppia pressione", "long": "{subtype} premuto a lungo", + "long_push": "{subtype} pressione prolungata", "long_single": "{subtype} premuto a lungo e poi singolarmente", "single": "{subtype} premuto singolarmente", "single_long": "{subtype} premuto singolarmente e poi a lungo", + "single_push": "{subtype} singola pressione", "triple": "{subtype} premuto tre volte" } } diff --git a/homeassistant/components/shelly/translations/nl.json b/homeassistant/components/shelly/translations/nl.json index 4a58fa31d85..0251e2e7267 100644 --- a/homeassistant/components/shelly/translations/nl.json +++ b/homeassistant/components/shelly/translations/nl.json @@ -33,14 +33,20 @@ "button": "Knop", "button1": "Eerste knop", "button2": "Tweede knop", - "button3": "Derde knop" + "button3": "Derde knop", + "button4": "Vierde knop" }, "trigger_type": { + "btn_down": "{subtype} knop omlaag", + "btn_up": "{subtype} knop omhoog", "double": "{subtype} dubbel geklikt", + "double_push": "{subtype} dubbele druk", "long": "{subtype} lang geklikt", + "long_push": " {subtype} lange druk", "long_single": "{subtype} lang geklikt en daarna \u00e9\u00e9n keer geklikt", "single": "{subtype} enkel geklikt", "single_long": "{subtype} een keer geklikt en daarna lang geklikt", + "single_push": "{subtype} een druk", "triple": "{subtype} driemaal geklikt" } } diff --git a/homeassistant/components/shelly/translations/no.json b/homeassistant/components/shelly/translations/no.json index 90cfe3ca906..dd587e56a6b 100644 --- a/homeassistant/components/shelly/translations/no.json +++ b/homeassistant/components/shelly/translations/no.json @@ -33,14 +33,20 @@ "button": "Knapp", "button1": "F\u00f8rste knapp", "button2": "Andre knapp", - "button3": "Tredje knapp" + "button3": "Tredje knapp", + "button4": "Fjerde knapp" }, "trigger_type": { + "btn_down": "{subtype}-knappen ned", + "btn_up": "{subtype} -knappen opp", "double": "{subtype} dobbeltklikket", + "double_push": "{subtype} dobbelt trykk", "long": "{subtype} lenge klikket", + "long_push": "{subtype} langt trykk", "long_single": "{subtype} lengre klikk og deretter et enkeltklikk", "single": "{subtype} enkeltklikket", "single_long": "{subtype} enkeltklikket og deretter et lengre klikk", + "single_push": "{subtype} enkelt trykk", "triple": "{subtype} trippelklikket" } } diff --git a/homeassistant/components/shelly/translations/ru.json b/homeassistant/components/shelly/translations/ru.json index 9996e347e96..d3f38aa9eeb 100644 --- a/homeassistant/components/shelly/translations/ru.json +++ b/homeassistant/components/shelly/translations/ru.json @@ -24,7 +24,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0440\u0430\u0431\u043e\u0442\u0430\u044e\u0449\u0438\u0435 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u0432\u0435\u0441\u0442\u0438 \u0438\u0437 \u0441\u043f\u044f\u0449\u0435\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430, \u043d\u0430\u0436\u0430\u0432 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435." + "description": "\u0420\u0430\u0431\u043e\u0442\u0430\u044e\u0449\u0438\u0435 \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0447\u0430\u043b\u043e\u043c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u044b\u0432\u0435\u0441\u0442\u0438 \u0438\u0437 \u0441\u043f\u044f\u0449\u0435\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430. \u042d\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043d\u043e\u043f\u043a\u0438, \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0439 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435." } } }, @@ -33,14 +33,20 @@ "button": "\u041a\u043d\u043e\u043f\u043a\u0430", "button1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "button2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", - "button3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430" + "button3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430" }, "trigger_type": { + "btn_down": "{subtype} \u0432 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438 '\u0432\u043d\u0438\u0437'", + "btn_up": "{subtype} \u0432 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438 '\u0432\u0432\u0435\u0440\u0445'", "double": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", + "double_push": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", "long": "{subtype} \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "long_push": "{subtype} \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430", "long_single": "{subtype} \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430 \u0438 \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437", "single": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437", "single_long": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0438 \u0437\u0430\u0442\u0435\u043c \u0434\u043e\u043b\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "single_push": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043e\u0434\u0438\u043d \u0440\u0430\u0437", "triple": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430" } } diff --git a/homeassistant/components/shelly/translations/zh-Hant.json b/homeassistant/components/shelly/translations/zh-Hant.json index d0e255560be..bc746ccac2a 100644 --- a/homeassistant/components/shelly/translations/zh-Hant.json +++ b/homeassistant/components/shelly/translations/zh-Hant.json @@ -33,14 +33,20 @@ "button": "\u6309\u9215", "button1": "\u7b2c\u4e00\u500b\u6309\u9215", "button2": "\u7b2c\u4e8c\u500b\u6309\u9215", - "button3": "\u7b2c\u4e09\u500b\u6309\u9215" + "button3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button4": "\u7b2c\u56db\u500b\u6309\u9215" }, "trigger_type": { + "btn_down": "\"{subtype}\" \u6309\u9215\u6309\u4e0b", + "btn_up": "\"{subtype}\" \u6309\u9215\u91cb\u653e", "double": "{subtype} \u96d9\u64ca", + "double_push": "{subtype} \u96d9\u6309", "long": "{subtype} \u9577\u6309", + "long_push": "{subtype} \u9577\u6309", "long_single": "{subtype} \u9577\u6309\u5f8c\u55ae\u64ca", "single": "{subtype} \u55ae\u64ca", "single_long": "{subtype} \u55ae\u64ca\u5f8c\u9577\u6309", + "single_push": "{subtype} \u55ae\u6309", "triple": "{subtype} \u4e09\u9023\u64ca" } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d1e2947d5ac..6f24b4a64be 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -5,8 +5,11 @@ from datetime import datetime, timedelta import logging from typing import Any, Final, cast -import aioshelly +from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice +from aioshelly.const import MODEL_NAMES +from aioshelly.rpc_device import RpcDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton @@ -18,6 +21,8 @@ from .const import ( CONF_COAP_PORT, DEFAULT_COAP_PORT, DOMAIN, + MAX_RPC_KEY_INSTANCES, + RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHIX3_1_INPUTS_EVENTS_TYPES, @@ -40,18 +45,27 @@ async def async_remove_shelly_entity( def temperature_unit(block_info: dict[str, Any]) -> str: """Detect temperature unit.""" - if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": + if block_info[BLOCK_VALUE_UNIT] == "F": return TEMP_FAHRENHEIT return TEMP_CELSIUS -def get_device_name(device: aioshelly.Device) -> str: +def get_block_device_name(device: BlockDevice) -> str: """Naming for device.""" return cast(str, device.settings["name"] or device.settings["device"]["hostname"]) -def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> int: +def get_rpc_device_name(device: RpcDevice) -> str: + """Naming for device.""" + # Gen2 does not support setting device name + # AP SSID name is used as a nicely formatted device name + return cast(str, device.config["wifi"]["ap"]["ssid"] or device.hostname) + + +def get_number_of_channels(device: BlockDevice, block: Block) -> int: """Get number of channels for block type.""" + assert isinstance(device.shelly, dict) + channels = None if block.type == "input": @@ -70,13 +84,13 @@ def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> return channels or 1 -def get_entity_name( - device: aioshelly.Device, - block: aioshelly.Block, +def get_block_entity_name( + device: BlockDevice, + block: Block | None, description: str | None = None, ) -> str: - """Naming for switch and sensors.""" - channel_name = get_device_channel_name(device, block) + """Naming for block based switch and sensors.""" + channel_name = get_block_channel_name(device, block) if description: return f"{channel_name} {description}" @@ -84,12 +98,9 @@ def get_entity_name( return channel_name -def get_device_channel_name( - device: aioshelly.Device, - block: aioshelly.Block, -) -> str: +def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: """Get name based on device and channel name.""" - entity_name = get_device_name(device) + entity_name = get_block_device_name(device) if ( not block @@ -98,8 +109,10 @@ def get_device_channel_name( ): return entity_name + assert block.channel + channel_name: str | None = None - mode = block.type + "s" + mode = cast(str, block.type) + "s" if mode in device.settings: channel_name = device.settings[mode][int(block.channel)].get("name") @@ -114,8 +127,8 @@ def get_device_channel_name( return f"{entity_name} channel {chr(int(block.channel)+base)}" -def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool: - """Return true if input button settings is set to a momentary type.""" +def is_block_momentary_input(settings: dict[str, Any], block: Block) -> bool: + """Return true if block 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: return True @@ -136,9 +149,9 @@ def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool return button_type in ["momentary", "momentary_on_release"] -def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: +def get_device_uptime(uptime: float, last_uptime: str | None) -> str: """Return device uptime string, tolerate up to 5 seconds deviation.""" - delta_uptime = utcnow() - timedelta(seconds=status["uptime"]) + delta_uptime = utcnow() - timedelta(seconds=uptime) if ( not last_uptime @@ -150,14 +163,14 @@ def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: return last_uptime -def get_input_triggers( - device: aioshelly.Device, block: aioshelly.Block +def get_block_input_triggers( + device: BlockDevice, block: Block ) -> list[tuple[str, str]]: """Return list of input triggers for block.""" if "inputEvent" not in block.sensor_ids or "inputEventCnt" not in block.sensor_ids: return [] - if not is_momentary_input(device.settings, block): + if not is_block_momentary_input(device.settings, block): return [] triggers = [] @@ -165,6 +178,7 @@ def get_input_triggers( if block.type == "device" or get_number_of_channels(device, block) == 1: subtype = "button" else: + assert block.channel subtype = f"button{int(block.channel)+1}" if device.settings["device"]["type"] in SHBTN_MODELS: @@ -180,10 +194,20 @@ def get_input_triggers( return triggers +def get_shbtn_input_triggers() -> list[tuple[str, str]]: + """Return list of input triggers for SHBTN models.""" + triggers = [] + + for trigger_type in SHBTN_INPUTS_EVENTS_TYPES: + triggers.append((trigger_type, "button")) + + return triggers + + @singleton.singleton("shelly_coap") -async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP: +async def get_coap_context(hass: HomeAssistant) -> COAP: """Get CoAP context to be used in all Shelly devices.""" - context = aioshelly.COAP() + context = COAP() if DOMAIN in hass.data: port = hass.data[DOMAIN].get(CONF_COAP_PORT, DEFAULT_COAP_PORT) else: @@ -200,7 +224,7 @@ async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP: return context -def get_device_sleep_period(settings: dict[str, Any]) -> int: +def get_block_device_sleep_period(settings: dict[str, Any]) -> int: """Return the device sleep period in seconds or 0 for non sleeping devices.""" sleep_period = 0 @@ -210,3 +234,114 @@ def get_device_sleep_period(settings: dict[str, Any]) -> int: sleep_period *= 60 # hours to minutes return sleep_period * 60 # minutes to seconds + + +def get_info_auth(info: dict[str, Any]) -> bool: + """Return true if device has authorization enabled.""" + return cast(bool, info.get("auth") or info.get("auth_en")) + + +def get_info_gen(info: dict[str, Any]) -> int: + """Return the device generation from shelly info.""" + return int(info.get("gen", 1)) + + +def get_model_name(info: dict[str, Any]) -> str: + """Return the device model name.""" + if get_info_gen(info) == 2: + return cast(str, MODEL_NAMES.get(info["model"], info["model"])) + + return cast(str, MODEL_NAMES.get(info["type"], info["type"])) + + +def get_rpc_channel_name(device: RpcDevice, key: str) -> str: + """Get name based on device and channel name.""" + key = key.replace("input", "switch") + device_name = get_rpc_device_name(device) + entity_name: str | None = device.config[key].get("name", device_name) + + if entity_name is None: + return f"{device_name} {key.replace(':', '_')}" + + return entity_name + + +def get_rpc_entity_name( + device: RpcDevice, key: str, description: str | None = None +) -> str: + """Naming for RPC based switch and sensors.""" + channel_name = get_rpc_channel_name(device, key) + + if description: + return f"{channel_name} {description}" + + return channel_name + + +def get_device_entry_gen(entry: ConfigEntry) -> int: + """Return the device generation from config entry.""" + return entry.data.get("gen", 1) + + +def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: + """Return list of key instances for RPC device from a dict.""" + if key in keys_dict: + return [key] + + keys_list: list[str] = [] + for i in range(MAX_RPC_KEY_INSTANCES): + key_inst = f"{key}:{i}" + if key_inst not in keys_dict: + return keys_list + + keys_list.append(key_inst) + + return keys_list + + +def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: + """Return list of key ids for RPC device from a dict.""" + key_ids: list[int] = [] + for i in range(MAX_RPC_KEY_INSTANCES): + key_inst = f"{key}:{i}" + if key_inst not in keys_dict: + return key_ids + + key_ids.append(i) + + return key_ids + + +def is_rpc_momentary_input(config: dict[str, Any], key: str) -> bool: + """Return true if rpc input button settings is set to a momentary type.""" + return cast(bool, config[key]["type"] == "button") + + +def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool: + """Return true if block channel appliance type is set to light.""" + app_type = settings["relays"][channel].get("appliance_type") + return app_type is not None and app_type.lower().startswith("light") + + +def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: + """Return true if rpc channel consumption type is set to light.""" + con_types = config["sys"]["ui_data"].get("consumption_types") + return con_types is not None and con_types[channel].lower().startswith("light") + + +def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: + """Return list of input triggers for RPC device.""" + triggers = [] + + key_ids = get_rpc_key_ids(device.config, "input") + + for id_ in key_ids: + key = f"input:{id_}" + if not is_rpc_momentary_input(device.config, key): + continue + + for trigger_type in RPC_INPUTS_EVENTS_TYPES: + subtype = f"button{id_+1}" + triggers.append((trigger_type, subtype)) + + return triggers diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 49b4d8a5d91..a38720bef59 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,4 +1,5 @@ """Support to manage a shopping list.""" +from http import HTTPStatus import logging import uuid @@ -7,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_NAME, HTTP_BAD_REQUEST, HTTP_NOT_FOUND +from homeassistant.const import ATTR_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json @@ -293,9 +294,9 @@ class UpdateShoppingListItemView(http.HomeAssistantView): request.app["hass"].bus.async_fire(EVENT) return self.json(item) except KeyError: - return self.json_message("Item not found", HTTP_NOT_FOUND) + return self.json_message("Item not found", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("Item not found", HTTP_BAD_REQUEST) + return self.json_message("Item not found", HTTPStatus.BAD_REQUEST) class CreateShoppingListItemView(http.HomeAssistantView): diff --git a/homeassistant/components/shopping_list/translations/fr.json b/homeassistant/components/shopping_list/translations/fr.json index b5265a70784..73822cc49f0 100644 --- a/homeassistant/components/shopping_list/translations/fr.json +++ b/homeassistant/components/shopping_list/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "La liste d'achats est d\u00e9j\u00e0 configur\u00e9e." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "step": { "user": { diff --git a/homeassistant/components/shopping_list/translations/hu.json b/homeassistant/components/shopping_list/translations/hu.json index 5f092963da3..27c984ce1ae 100644 --- a/homeassistant/components/shopping_list/translations/hu.json +++ b/homeassistant/components/shopping_list/translations/hu.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a bev\u00e1s\u00e1rl\u00f3list\u00e1t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a bev\u00e1s\u00e1rl\u00f3list\u00e1t?", "title": "Bev\u00e1s\u00e1rl\u00f3lista" } } diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index e5f77700409..2d7c81072f6 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -1,5 +1,8 @@ """Support for Sensirion SHT31 temperature and humidity sensor.""" +from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta import logging import math @@ -7,10 +10,15 @@ import math from Adafruit_SHT31 import SHT31 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, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, @@ -25,9 +33,40 @@ CONF_I2C_ADDRESS = "i2c_address" DEFAULT_NAME = "SHT31" DEFAULT_I2C_ADDRESS = 0x44 -SENSOR_TEMPERATURE = "temperature" -SENSOR_HUMIDITY = "humidity" -SENSOR_TYPES = (SENSOR_TEMPERATURE, SENSOR_HUMIDITY) + +@dataclass +class SHT31RequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[SHTClient], float | None] + + +@dataclass +class SHT31SensorEntityDescription(SensorEntityDescription, SHT31RequiredKeysMixin): + """Describes SHT31 sensor entity.""" + + +SENSOR_TYPES = ( + SHT31SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + value_fn=lambda sensor: sensor.temperature, + ), + SHT31SensorEntityDescription( + key="humidity", + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda sensor: ( + round(val) # pylint: disable=undefined-variable + if (val := sensor.humidity) + else None + ), + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -36,8 +75,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): vol.All( vol.Coerce(int), vol.Range(min=0x44, max=0x45) ), - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } @@ -46,8 +85,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the sensor platform.""" - - i2c_address = config.get(CONF_I2C_ADDRESS) + name = config[CONF_NAME] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + i2c_address = config[CONF_I2C_ADDRESS] sensor = SHT31(address=i2c_address) try: @@ -58,17 +98,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return sensor_client = SHTClient(sensor) - sensor_classes = { - SENSOR_TEMPERATURE: SHTSensorTemperature, - SENSOR_HUMIDITY: SHTSensorHumidity, - } + entities = [ + SHTSensor(sensor_client, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - devs = [] - for sensor_type, sensor_class in sensor_classes.items(): - name = f"{config.get(CONF_NAME)} {sensor_type.capitalize()}" - devs.append(sensor_class(sensor_client, name)) - - add_entities(devs) + add_entities(entities) class SHTClient: @@ -77,8 +113,8 @@ class SHTClient: def __init__(self, adafruit_sht): """Initialize the sensor.""" self.adafruit_sht = adafruit_sht - self.temperature = None - self.humidity = None + self.temperature: float | None = None + self.humidity: float | None = None @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -94,50 +130,16 @@ class SHTClient: class SHTSensor(SensorEntity): """An abstract SHTSensor, can be either temperature or humidity.""" - def __init__(self, sensor, name): + entity_description: SHT31SensorEntityDescription + + def __init__(self, sensor, name, description: SHT31SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._sensor = sensor - self._name = name - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{name} {description.name}" def update(self): """Fetch temperature and humidity from the sensor.""" self._sensor.update() - - -class SHTSensorTemperature(SHTSensor): - """Representation of a temperature sensor.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_native_unit_of_measurement = TEMP_CELSIUS - - def update(self): - """Fetch temperature from the sensor.""" - super().update() - self._state = self._sensor.temperature - - -class SHTSensorHumidity(SHTSensor): - """Representation of a humidity sensor.""" - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return PERCENTAGE - - def update(self): - """Fetch humidity from the sensor.""" - super().update() - humidity = self._sensor.humidity - if humidity is not None: - self._state = round(humidity) + self._attr_native_value = self.entity_description.value_fn(self._sensor) diff --git a/homeassistant/components/sia/manifest.json b/homeassistant/components/sia/manifest.json index eaeb4547167..438d63a1830 100644 --- a/homeassistant/components/sia/manifest.json +++ b/homeassistant/components/sia/manifest.json @@ -3,7 +3,7 @@ "name": "SIA Alarm Systems", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", - "requirements": ["pysiaalarm==3.0.0"], + "requirements": ["pysiaalarm==3.0.1"], "codeowners": ["@eavanvalkenburg"], "iot_class": "local_push" } diff --git a/homeassistant/components/sia/translations/es.json b/homeassistant/components/sia/translations/es.json index f32b6a86626..8e1bb05978d 100644 --- a/homeassistant/components/sia/translations/es.json +++ b/homeassistant/components/sia/translations/es.json @@ -6,10 +6,18 @@ "invalid_key_format": "La clave no es un valor hexadecimal, por favor utilice s\u00f3lo 0-9 y A-F.", "invalid_key_length": "La clave no tiene la longitud correcta, tiene que ser de 16, 24 o 32 caracteres hexadecimales.", "invalid_ping": "El intervalo de ping debe estar entre 1 y 1440 minutos.", - "invalid_zones": "Tiene que haber al menos 1 zona." + "invalid_zones": "Tiene que haber al menos 1 zona.", + "unknown": "Error inesperado" }, "step": { "additional_account": { + "data": { + "account": "ID de la cuenta", + "additional_account": "Cuentas adicionales", + "encryption_key": "Clave de encriptaci\u00f3n", + "ping_interval": "Intervalo de ping (min)", + "zones": "N\u00famero de zonas de la cuenta" + }, "title": "Agrega otra cuenta al puerto actual." }, "user": { @@ -18,6 +26,7 @@ "additional_account": "Cuentas adicionales", "encryption_key": "Clave de encriptaci\u00f3n", "ping_interval": "Intervalo de ping (min)", + "port": "Puerto", "protocol": "Protocolo", "zones": "N\u00famero de zonas de la cuenta" }, @@ -29,7 +38,8 @@ "step": { "options": { "data": { - "ignore_timestamps": "Ignore la verificaci\u00f3n de la marca de tiempo de los eventos SIA" + "ignore_timestamps": "Ignore la verificaci\u00f3n de la marca de tiempo de los eventos SIA", + "zones": "N\u00famero de zonas de la cuenta" }, "description": "Configure las opciones para la cuenta: {account}", "title": "Opciones para la configuraci\u00f3n de SIA." diff --git a/homeassistant/components/sia/translations/fr.json b/homeassistant/components/sia/translations/fr.json index 2b3188dd082..843c707ce19 100644 --- a/homeassistant/components/sia/translations/fr.json +++ b/homeassistant/components/sia/translations/fr.json @@ -12,7 +12,7 @@ "step": { "additional_account": { "data": { - "account": "Identifiant du compte", + "account": "Identifiant de compte", "additional_account": "Comptes suppl\u00e9mentaires", "encryption_key": "Cl\u00e9 de cryptage", "ping_interval": "Intervalle de ping (min)", diff --git a/homeassistant/components/sia/translations/id.json b/homeassistant/components/sia/translations/id.json new file mode 100644 index 00000000000..e7ab7918fb3 --- /dev/null +++ b/homeassistant/components/sia/translations/id.json @@ -0,0 +1,50 @@ +{ + "config": { + "error": { + "invalid_account_format": "Format akun ini tidak dalam nilai heksadesimal, gunakan hanya karakter 0-9 dan A-F.", + "invalid_account_length": "Panjang format akun tidak tepat, harus antara 3 dan 16 karakter.", + "invalid_key_format": "Format kunci ini tidak dalam nilai heksadesimal, gunakan hanya karakter 0-9 dan A-F.", + "invalid_key_length": "Panjang format kunci tidak tepat, harus antara 16, 25, atau 32 karakter heksadesimal.", + "invalid_ping": "Interval ping harus antara 1 dan 1440 menit.", + "invalid_zones": "Setidaknya harus ada 1 zona.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "additional_account": { + "data": { + "account": "ID Akun", + "additional_account": "Akun lainnya", + "encryption_key": "Kunci Enkripsi", + "ping_interval": "Interval Ping (menit)", + "zones": "Jumlah zona untuk akun" + }, + "title": "Tambahkan akun lain ke port saat ini." + }, + "user": { + "data": { + "account": "ID Akun", + "additional_account": "Akun lainnya", + "encryption_key": "Kunci Enkripsi", + "ping_interval": "Interval Ping (menit)", + "port": "Port", + "protocol": "Protokol", + "zones": "Jumlah zona untuk akun" + }, + "title": "Buat koneksi untuk sistem alarm berbasis SIA." + } + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Abaikan pemeriksaan stempel waktu peristiwa SIA", + "zones": "Jumlah zona untuk akun" + }, + "description": "Setel opsi untuk akun: {account}", + "title": "Opsi untuk Pengaturan SIA." + } + } + }, + "title": "Sistem Alarm SIA" +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 924cf398f64..4ba26f0adc7 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable -from typing import Callable, cast +from collections.abc import Awaitable, Callable +from typing import cast from uuid import UUID from simplipy import get_api diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index c3f8d7c3ab0..fceb90fc9eb 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -1,7 +1,7 @@ """Support for SimpliSafe freeze sensor.""" from simplipy.entity import EntityTypes -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_TEMPERATURE, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback @@ -35,6 +35,7 @@ class SimplisafeFreezeSensor(SimpliSafeBaseSensor, SensorEntity): _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_native_unit_of_measurement = TEMP_FAHRENHEIT + _attr_state_class = STATE_CLASS_MEASUREMENT @callback def async_update_from_rest_api(self) -> None: diff --git a/homeassistant/components/simplisafe/translations/fr.json b/homeassistant/components/simplisafe/translations/fr.json index 1627f41c212..b1ea8441369 100644 --- a/homeassistant/components/simplisafe/translations/fr.json +++ b/homeassistant/components/simplisafe/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Ce compte SimpliSafe est d\u00e9j\u00e0 utilis\u00e9.", - "reauth_successful": "SimpliSafe a \u00e9t\u00e9 r\u00e9 authentifi\u00e9 avec succ\u00e8s." + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9", @@ -20,13 +20,13 @@ "password": "Mot de passe" }, "description": "Votre jeton d'acc\u00e8s a expir\u00e9 ou a \u00e9t\u00e9 r\u00e9voqu\u00e9. Entrez votre mot de passe pour r\u00e9 associer votre compte.", - "title": "Relier le compte SimpliSafe" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { "code": "Code (utilis\u00e9 dans l'interface Home Assistant)", "password": "Mot de passe", - "username": "Adresse e-mail" + "username": "Email" }, "title": "Veuillez saisir vos informations" } diff --git a/homeassistant/components/simplisafe/translations/hu.json b/homeassistant/components/simplisafe/translations/hu.json index f7c1b5afd9d..ed0eb0b2212 100644 --- a/homeassistant/components/simplisafe/translations/hu.json +++ b/homeassistant/components/simplisafe/translations/hu.json @@ -28,7 +28,7 @@ "password": "Jelsz\u00f3", "username": "E-mail" }, - "title": "T\u00f6ltsd ki az adataid" + "title": "T\u00f6ltse ki az adatait" } } }, diff --git a/homeassistant/components/simplisafe/translations/id.json b/homeassistant/components/simplisafe/translations/id.json index 512d6a38405..c9ff0f96bb9 100644 --- a/homeassistant/components/simplisafe/translations/id.json +++ b/homeassistant/components/simplisafe/translations/id.json @@ -19,7 +19,7 @@ "data": { "password": "Kata Sandi" }, - "description": "Token akses Anda telah kedaluwarsa atau dicabut. Masukkan kata sandi Anda untuk menautkan kembali akun Anda.", + "description": "Akses Anda telah kedaluwarsa atau dicabut. Masukkan kata sandi Anda untuk menautkan kembali akun Anda.", "title": "Autentikasi Ulang Integrasi" }, "user": { diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index a462d0c854b..da24627d268 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.5"], + "requirements": ["pysma==0.6.6"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index f0a10a5d5e1..922ec9f9212 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -166,10 +166,10 @@ 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: + if self.native_unit_of_measurement == ENERGY_KILO_WATT_HOUR: self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._attr_device_class = DEVICE_CLASS_ENERGY - if self.unit_of_measurement == POWER_WATT: + if self.native_unit_of_measurement == POWER_WATT: self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_device_class = DEVICE_CLASS_POWER diff --git a/homeassistant/components/sma/translations/fr.json b/homeassistant/components/sma/translations/fr.json index e70401c87f5..46b3be072f7 100644 --- a/homeassistant/components/sma/translations/fr.json +++ b/homeassistant/components/sma/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours" + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -14,7 +14,7 @@ "user": { "data": { "group": "Groupe", - "host": "H\u00f4te ", + "host": "H\u00f4te", "password": "Mot de passe", "ssl": "Utilise un certificat SSL", "verify_ssl": "V\u00e9rifier le certificat SSL" diff --git a/homeassistant/components/sma/translations/hu.json b/homeassistant/components/sma/translations/hu.json index cab063cd077..f1958dbcc1f 100644 --- a/homeassistant/components/sma/translations/hu.json +++ b/homeassistant/components/sma/translations/hu.json @@ -14,7 +14,7 @@ "user": { "data": { "group": "Csoport", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "password": "Jelsz\u00f3", "ssl": "SSL tan\u00fas\u00edtv\u00e1nyt haszn\u00e1l", "verify_ssl": "Ellen\u0151rizze az SSL tan\u00fas\u00edtv\u00e1nyt" diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index b4250332120..91192a13484 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smappee", "dependencies": ["http"], "requirements": [ - "pysmappee==0.2.25" + "pysmappee==0.2.27" ], "codeowners": [ "@bsmappee" diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index ec93501a508..af66b788a41 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,8 +1,13 @@ """Support for monitoring a Smappee energy sensor.""" +from __future__ import annotations + +from dataclasses import dataclass, field + from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( DEVICE_CLASS_ENERGY, @@ -16,141 +21,177 @@ from homeassistant.const import ( from .const import DOMAIN -TREND_SENSORS = { - "total_power": [ - "Total consumption - Active power", - None, - POWER_WATT, - "total_power", - DEVICE_CLASS_POWER, - True, # both cloud and local - ], - "alwayson": [ - "Always on - Active power", - None, - POWER_WATT, - "alwayson", - DEVICE_CLASS_POWER, - False, # cloud only - ], - "power_today": [ - "Total consumption - Today", - None, - ENERGY_WATT_HOUR, - "power_today", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "power_current_hour": [ - "Total consumption - Current hour", - None, - ENERGY_WATT_HOUR, - "power_current_hour", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "power_last_5_minutes": [ - "Total consumption - Last 5 minutes", - None, - ENERGY_WATT_HOUR, - "power_last_5_minutes", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "alwayson_today": [ - "Always on - Today", - None, - ENERGY_WATT_HOUR, - "alwayson_today", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], -} -REACTIVE_SENSORS = { - "total_reactive_power": [ - "Total consumption - Reactive power", - None, - POWER_WATT, - "total_reactive_power", - DEVICE_CLASS_POWER, - ] -} -SOLAR_SENSORS = { - "solar_power": [ - "Total production - Active power", - None, - POWER_WATT, - "solar_power", - DEVICE_CLASS_POWER, - True, # both cloud and local - ], - "solar_today": [ - "Total production - Today", - None, - ENERGY_WATT_HOUR, - "solar_today", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - "solar_current_hour": [ - "Total production - Current hour", - None, - ENERGY_WATT_HOUR, - "solar_current_hour", - DEVICE_CLASS_ENERGY, - False, # cloud only - ], -} -VOLTAGE_SENSORS = { - "phase_voltages_a": [ - "Phase voltages - A", - None, - ELECTRIC_POTENTIAL_VOLT, - "phase_voltage_a", - DEVICE_CLASS_VOLTAGE, - ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], - ], - "phase_voltages_b": [ - "Phase voltages - B", - None, - ELECTRIC_POTENTIAL_VOLT, - "phase_voltage_b", - DEVICE_CLASS_VOLTAGE, - ["TWO", "THREE_STAR", "THREE_DELTA"], - ], - "phase_voltages_c": [ - "Phase voltages - C", - None, - ELECTRIC_POTENTIAL_VOLT, - "phase_voltage_c", - DEVICE_CLASS_VOLTAGE, - ["THREE_STAR"], - ], - "line_voltages_a": [ - "Line voltages - A", - None, - ELECTRIC_POTENTIAL_VOLT, - "line_voltage_a", - DEVICE_CLASS_VOLTAGE, - ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], - ], - "line_voltages_b": [ - "Line voltages - B", - None, - ELECTRIC_POTENTIAL_VOLT, - "line_voltage_b", - DEVICE_CLASS_VOLTAGE, - ["TWO", "THREE_STAR", "THREE_DELTA"], - ], - "line_voltages_c": [ - "Line voltages - C", - None, - ELECTRIC_POTENTIAL_VOLT, - "line_voltage_c", - DEVICE_CLASS_VOLTAGE, - ["THREE_STAR", "THREE_DELTA"], - ], -} + +@dataclass +class SmappeeRequiredKeysMixin: + """Mixin for required keys.""" + + sensor_id: str + + +@dataclass +class SmappeeSensorEntityDescription(SensorEntityDescription, SmappeeRequiredKeysMixin): + """Describes Smappee sensor entity.""" + + +@dataclass +class SmappeePollingSensorEntityDescription(SmappeeSensorEntityDescription): + """Describes Smappee sensor entity.""" + + local_polling: bool = False + + +@dataclass +class SmappeeVoltageSensorEntityDescription(SmappeeSensorEntityDescription): + """Describes Smappee sensor entity.""" + + phase_types: set[str] = field(default_factory=set) + + +TREND_SENSORS: tuple[SmappeePollingSensorEntityDescription, ...] = ( + SmappeePollingSensorEntityDescription( + key="total_power", + name="Total consumption - Active power", + native_unit_of_measurement=POWER_WATT, + sensor_id="total_power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + local_polling=True, # both cloud and local + ), + SmappeePollingSensorEntityDescription( + key="alwayson", + name="Always on - Active power", + native_unit_of_measurement=POWER_WATT, + sensor_id="alwayson", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SmappeePollingSensorEntityDescription( + key="power_today", + name="Total consumption - Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="power_today", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="power_current_hour", + name="Total consumption - Current hour", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="power_current_hour", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="power_last_5_minutes", + name="Total consumption - Last 5 minutes", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="power_last_5_minutes", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="alwayson_today", + name="Always on - Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="alwayson_today", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) +REACTIVE_SENSORS: tuple[SmappeeSensorEntityDescription, ...] = ( + SmappeeSensorEntityDescription( + key="total_reactive_power", + name="Total consumption - Reactive power", + native_unit_of_measurement=POWER_WATT, + sensor_id="total_reactive_power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), +) +SOLAR_SENSORS: tuple[SmappeePollingSensorEntityDescription, ...] = ( + SmappeePollingSensorEntityDescription( + key="solar_power", + name="Total production - Active power", + native_unit_of_measurement=POWER_WATT, + sensor_id="solar_power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + local_polling=True, # both cloud and local + ), + SmappeePollingSensorEntityDescription( + key="solar_today", + name="Total production - Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="solar_today", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SmappeePollingSensorEntityDescription( + key="solar_current_hour", + name="Total production - Current hour", + native_unit_of_measurement=ENERGY_WATT_HOUR, + sensor_id="solar_current_hour", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) +VOLTAGE_SENSORS: tuple[SmappeeVoltageSensorEntityDescription, ...] = ( + SmappeeVoltageSensorEntityDescription( + key="phase_voltages_a", + name="Phase voltages - A", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="phase_voltage_a", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"ONE", "TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="phase_voltages_b", + name="Phase voltages - B", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="phase_voltage_b", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="phase_voltages_c", + name="Phase voltages - C", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="phase_voltage_c", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"THREE_STAR"}, + ), + SmappeeVoltageSensorEntityDescription( + key="line_voltages_a", + name="Line voltages - A", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="line_voltage_a", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"ONE", "TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="line_voltages_b", + name="Line voltages - B", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="line_voltage_b", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"TWO", "THREE_STAR", "THREE_DELTA"}, + ), + SmappeeVoltageSensorEntityDescription( + key="line_voltages_c", + name="Line voltages - C", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + sensor_id="line_voltage_c", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + phase_types={"THREE_STAR", "THREE_DELTA"}, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -161,116 +202,125 @@ 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, 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=attributes, - ) - ) - - if service_location.has_reactive_value: - for reactive_sensor, attributes in REACTIVE_SENSORS.items(): - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor=reactive_sensor, - attributes=attributes, - ) - ) - - # Add solar sensors (some are available in local only env) - if service_location.has_solar_production: - 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=attributes, - ) - ) - - # Add all CT measurements - for measurement_id, measurement in service_location.measurements.items(): - entities.append( + entities.extend( + [ SmappeeSensor( smappee_base=smappee_base, service_location=service_location, - sensor="load", - attributes=[ - measurement.name, - None, - POWER_WATT, - measurement_id, - DEVICE_CLASS_POWER, - ], + description=description, ) + for description in TREND_SENSORS + if not service_location.local_polling or description.local_polling + ] + ) + + if service_location.has_reactive_value: + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=description, + ) + for description in REACTIVE_SENSORS + ] ) + # Add solar sensors (some are available in local only env) + if service_location.has_solar_production: + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=description, + ) + for description in SOLAR_SENSORS + if not service_location.local_polling or description.local_polling + ] + ) + + # Add all CT measurements + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=SmappeeSensorEntityDescription( + key="load", + name=measurement.name, + sensor_id=measurement_id, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ) + for measurement_id, measurement in service_location.measurements.items() + ] + ) + # Add phase- and line voltages if available if service_location.has_voltage_values: - for sensor_name, sensor in VOLTAGE_SENSORS.items(): - if service_location.phase_type in sensor[5]: + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=description, + ) + for description in VOLTAGE_SENSORS if ( - sensor_name.startswith("line_") - and service_location.local_polling - ): - continue - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor=sensor_name, - attributes=sensor, + service_location.phase_type in description.phase_types + and not ( + description.key.startswith("line_") + and service_location.local_polling ) ) + ] + ) # Add Gas and Water sensors - for sensor_id, sensor in service_location.sensors.items(): - for channel in sensor.channels: - gw_icon = "mdi:gas-cylinder" - if channel.get("type") == "water": - gw_icon = "mdi:water" - - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor="sensor", - attributes=[ - channel.get("name"), - gw_icon, - channel.get("uom"), - f"{sensor_id}-{channel.get('channel')}", - None, - ], - ) + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=SmappeeSensorEntityDescription( + key="sensor", + name=channel.get("name"), + icon=( + "mdi:water" + if channel.get("type") == "water" + else "mdi:gas-cylinder" + ), + native_unit_of_measurement=channel.get("uom"), + sensor_id=f"{sensor_id}-{channel.get('channel')}", + state_class=STATE_CLASS_MEASUREMENT, + ), ) + for sensor_id, sensor in service_location.sensors.items() + for channel in sensor.channels + ] + ) # Add today_energy_kwh sensors for switches - for actuator_id, actuator in service_location.actuators.items(): - if actuator.type == "SWITCH": - entities.append( - SmappeeSensor( - smappee_base=smappee_base, - service_location=service_location, - sensor="switch", - attributes=[ - f"{actuator.name} - energy today", - None, - ENERGY_KILO_WATT_HOUR, - actuator_id, - DEVICE_CLASS_ENERGY, - False, # cloud only - ], - ) + entities.extend( + [ + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + description=SmappeeSensorEntityDescription( + key="switch", + name=f"{actuator.name} - energy today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + sensor_id=actuator_id, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), ) + for actuator_id, actuator in service_location.actuators.items() + if actuator.type == "SWITCH" + ] + ) async_add_entities(entities, True) @@ -278,84 +328,47 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmappeeSensor(SensorEntity): """Implementation of a Smappee sensor.""" - def __init__(self, smappee_base, service_location, sensor, attributes): + entity_description: SmappeeSensorEntityDescription + + def __init__( + self, + smappee_base, + service_location, + description: SmappeeSensorEntityDescription, + ): """Initialize the Smappee sensor.""" + self.entity_description = description self._smappee_base = smappee_base self._service_location = service_location - self._sensor = sensor - self.data = None - self._state = None - self._name = attributes[0] - self._icon = attributes[1] - self._unit_of_measurement = attributes[2] - self._sensor_id = attributes[3] - self._device_class = attributes[4] @property def name(self): """Return the name for this sensor.""" - if self._sensor in ("sensor", "load", "switch"): + sensor_key = self.entity_description.key + sensor_name = self.entity_description.name + if sensor_key in ("sensor", "load", "switch"): return ( f"{self._service_location.service_location_name} - " - f"{self._sensor.title()} - {self._name}" + f"{sensor_key.title()} - {sensor_name}" ) - return f"{self._service_location.service_location_name} - {self._name}" + return f"{self._service_location.service_location_name} - {sensor_name}" @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - - @property - def native_value(self): - """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 self._device_class - - @property - def state_class(self): - """Return the state class of this device.""" - scm = STATE_CLASS_MEASUREMENT - - if self._sensor in ( - "power_today", - "power_current_hour", - "power_last_5_minutes", - "solar_today", - "solar_current_hour", - "alwayson_today", - "switch", - ): - scm = STATE_CLASS_TOTAL_INCREASING - - return scm - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def unique_id( - self, - ): + def unique_id(self): """Return the unique ID for this sensor.""" - if self._sensor in ("load", "sensor", "switch"): + sensor_key = self.entity_description.key + if sensor_key in ("load", "sensor", "switch"): return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" - f"{self._sensor}-{self._sensor_id}" + f"{sensor_key}-{self.entity_description.sensor_id}" ) return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" - f"{self._sensor}" + f"{sensor_key}" ) @property @@ -373,37 +386,38 @@ class SmappeeSensor(SensorEntity): """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() - if self._sensor == "total_power": - self._state = self._service_location.total_power - elif self._sensor == "total_reactive_power": - self._state = self._service_location.total_reactive_power - elif self._sensor == "solar_power": - self._state = self._service_location.solar_power - elif self._sensor == "alwayson": - self._state = self._service_location.alwayson - elif self._sensor in ( + sensor_key = self.entity_description.key + if sensor_key == "total_power": + self._attr_native_value = self._service_location.total_power + elif sensor_key == "total_reactive_power": + self._attr_native_value = self._service_location.total_reactive_power + elif sensor_key == "solar_power": + self._attr_native_value = self._service_location.solar_power + elif sensor_key == "alwayson": + self._attr_native_value = self._service_location.alwayson + elif sensor_key in ( "phase_voltages_a", "phase_voltages_b", "phase_voltages_c", ): phase_voltages = self._service_location.phase_voltages if phase_voltages is not None: - if self._sensor == "phase_voltages_a": - self._state = phase_voltages[0] - elif self._sensor == "phase_voltages_b": - self._state = phase_voltages[1] - elif self._sensor == "phase_voltages_c": - self._state = phase_voltages[2] - elif self._sensor in ("line_voltages_a", "line_voltages_b", "line_voltages_c"): + if sensor_key == "phase_voltages_a": + self._attr_native_value = phase_voltages[0] + elif sensor_key == "phase_voltages_b": + self._attr_native_value = phase_voltages[1] + elif sensor_key == "phase_voltages_c": + self._attr_native_value = phase_voltages[2] + elif sensor_key in ("line_voltages_a", "line_voltages_b", "line_voltages_c"): line_voltages = self._service_location.line_voltages if line_voltages is not None: - if self._sensor == "line_voltages_a": - self._state = line_voltages[0] - elif self._sensor == "line_voltages_b": - self._state = line_voltages[1] - elif self._sensor == "line_voltages_c": - self._state = line_voltages[2] - elif self._sensor in ( + if sensor_key == "line_voltages_a": + self._attr_native_value = line_voltages[0] + elif sensor_key == "line_voltages_b": + self._attr_native_value = line_voltages[1] + elif sensor_key == "line_voltages_c": + self._attr_native_value = line_voltages[2] + elif sensor_key in ( "power_today", "power_current_hour", "power_last_5_minutes", @@ -411,21 +425,23 @@ class SmappeeSensor(SensorEntity): "solar_current_hour", "alwayson_today", ): - trend_value = self._service_location.aggregated_values.get(self._sensor) - self._state = round(trend_value) if trend_value is not None else None - elif self._sensor == "load": - self._state = self._service_location.measurements.get( - self._sensor_id + trend_value = self._service_location.aggregated_values.get(sensor_key) + self._attr_native_value = ( + round(trend_value) if trend_value is not None else None + ) + elif sensor_key == "load": + self._attr_native_value = self._service_location.measurements.get( + self.entity_description.sensor_id ).active_total - elif self._sensor == "sensor": - sensor_id, channel_id = self._sensor_id.split("-") + elif sensor_key == "sensor": + sensor_id, channel_id = self.entity_description.sensor_id.split("-") sensor = self._service_location.sensors.get(int(sensor_id)) for channel in sensor.channels: if channel.get("channel") == int(channel_id): - self._state = channel.get("value_today") - elif self._sensor == "switch": + self._attr_native_value = channel.get("value_today") + elif sensor_key == "switch": cons = self._service_location.actuators.get( - self._sensor_id + self.entity_description.sensor_id ).consumption_today if cons is not None: - self._state = round(cons / 1000.0, 2) + self._attr_native_value = round(cons / 1000.0, 2) diff --git a/homeassistant/components/smappee/translations/fr.json b/homeassistant/components/smappee/translations/fr.json index f1d8cdb9615..4bcdd0b5c16 100644 --- a/homeassistant/components/smappee/translations/fr.json +++ b/homeassistant/components/smappee/translations/fr.json @@ -24,7 +24,7 @@ "description": "Entrez l'h\u00f4te pour lancer l'int\u00e9gration locale Smappee" }, "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "zeroconf_confirm": { "description": "Voulez-vous ajouter l'appareil Smappee avec le num\u00e9ro de s\u00e9rie \u00ab {serialnumber} \u00bb \u00e0 Home Assistant?", diff --git a/homeassistant/components/smappee/translations/hu.json b/homeassistant/components/smappee/translations/hu.json index 5b00dffde9c..9c3d90ac43e 100644 --- a/homeassistant/components/smappee/translations/hu.json +++ b/homeassistant/components/smappee/translations/hu.json @@ -6,7 +6,7 @@ "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_mdns": "Nem t\u00e1mogatott eszk\u00f6z a Smappee integr\u00e1ci\u00f3hoz.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, 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." }, "flow_title": "{name}", @@ -15,13 +15,13 @@ "data": { "environment": "K\u00f6rnyezet" }, - "description": "\u00c1ll\u00edtsa be a Smappee k\u00e9sz\u00fcl\u00e9ket az HomeAssistant-al val\u00f3 integr\u00e1ci\u00f3hoz." + "description": "Integr\u00e1lja \u00f6ssze Smappee k\u00e9sz\u00fcl\u00e9ket HomeAssistanttal." }, "local": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "Adja meg a gazdag\u00e9pet a Smappee helyi integr\u00e1ci\u00f3j\u00e1nak elind\u00edt\u00e1s\u00e1hoz" + "description": "Adja meg a c\u00edmet a Smappee helyi integr\u00e1ci\u00f3j\u00e1nak elind\u00edt\u00e1s\u00e1hoz" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" diff --git a/homeassistant/components/smappee/translations/id.json b/homeassistant/components/smappee/translations/id.json index b72200c34ca..66efc23dcee 100644 --- a/homeassistant/components/smappee/translations/id.json +++ b/homeassistant/components/smappee/translations/id.json @@ -9,7 +9,7 @@ "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", "no_url_available": "Tidak ada URL yang tersedia. Untuk informasi tentang kesalahan ini, [lihat bagian bantuan]({docs_url})" }, - "flow_title": "Smappee: {name}", + "flow_title": "{name}", "step": { "environment": { "data": { diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 6914d3ef1ac..ed5c84f0bce 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -1,8 +1,8 @@ """Support for Smart Meter Texas sensors.""" from smart_meter_texas import Meter -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_ADDRESS, ENERGY_KILO_WATT_HOUR +from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING, SensorEntity +from homeassistant.const import CONF_ADDRESS, DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import ( @@ -33,6 +33,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of an Smart Meter Texas sensor.""" + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: diff --git a/homeassistant/components/smarthab/translations/fr.json b/homeassistant/components/smarthab/translations/fr.json index 7bc0bdc9531..efbbfe25818 100644 --- a/homeassistant/components/smarthab/translations/fr.json +++ b/homeassistant/components/smarthab/translations/fr.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "email": "Adresse email", + "email": "Email", "password": "Mot de passe" }, "description": "Pour des raisons techniques, utilisez un compte sp\u00e9cifique \u00e0 Home Assistant. Vous pouvez cr\u00e9er un compte secondaire depuis l'application SmartHab.", diff --git a/homeassistant/components/smartthings/translations/hu.json b/homeassistant/components/smartthings/translations/hu.json index 05e99bef2ea..90ea748ae33 100644 --- a/homeassistant/components/smartthings/translations/hu.json +++ b/homeassistant/components/smartthings/translations/hu.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "invalid_webhook_url": "A Home Assistant nincs megfelel\u0151en konfigur\u00e1lva a SmartThings friss\u00edt\u00e9seinek fogad\u00e1s\u00e1ra. A webhook URL \u00e9rv\u00e9nytelen:\n > {webhook_url} \n\n K\u00e9rj\u00fck, friss\u00edtse konfigur\u00e1ci\u00f3j\u00e1t az [utas\u00edt\u00e1sok] szerint ({component_url}), ind\u00edtsa \u00fajra a Home Assistant alkalmaz\u00e1st, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "invalid_webhook_url": "Home Assistant nincs megfelel\u0151en konfigur\u00e1lva a SmartThings friss\u00edt\u00e9seinek fogad\u00e1s\u00e1ra. A webhook URL \u00e9rv\u00e9nytelen:\n > {webhook_url} \n\nK\u00e9rj\u00fck, friss\u00edtse konfigur\u00e1ci\u00f3j\u00e1t az [utas\u00edt\u00e1sok]({component_url}) szerint, ind\u00edtsa \u00fajra a Home Assistant alkalmaz\u00e1st, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", "no_available_locations": "Nincsenek be\u00e1ll\u00edthat\u00f3 SmartThings helyek a Home Assistant alkalmaz\u00e1sban." }, "error": { - "app_setup_error": "A SmartApp be\u00e1ll\u00edt\u00e1sa nem siker\u00fclt. K\u00e9rlek pr\u00f3b\u00e1ld \u00fajra.", + "app_setup_error": "A SmartApp be\u00e1ll\u00edt\u00e1sa nem siker\u00fclt. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra.", "token_forbidden": "A token nem rendelkezik a sz\u00fcks\u00e9ges OAuth-tartom\u00e1nyokkal.", "token_invalid_format": "A tokennek UID / GUID form\u00e1tumban kell lennie", "token_unauthorized": "A token \u00e9rv\u00e9nytelen vagy m\u00e1r nem enged\u00e9lyezett.", - "webhook_error": "A SmartThings nem tudta \u00e9rv\u00e9nyes\u00edteni a `base_url`-ben konfigur\u00e1lt v\u00e9gpontot. K\u00e9rlek, tekintsd \u00e1t az \u00f6sszetev\u0151 k\u00f6vetelm\u00e9nyeit." + "webhook_error": "SmartThings nem tudta \u00e9rv\u00e9nyes\u00edteni a webhook URL-t. K\u00e9rj\u00fck, ellen\u0151rizze, hogy a webhook URL el\u00e9rhet\u0151-e az internet fel\u0151l, \u00e9s pr\u00f3b\u00e1lja meg \u00fajra." }, "step": { "authorize": { @@ -30,7 +30,7 @@ "title": "Hely kiv\u00e1laszt\u00e1sa" }, "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.", + "description": "K\u00e9rem adja meg a SmartThings [Personal Access Tokent]({token_url}), amit az [instrukci\u00f3k]({component_url}) alapj\u00e1n hozott l\u00e9tre.", "title": "Callback URL meger\u0151s\u00edt\u00e9se" } } diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 48b1d603c5c..adb7f3bf720 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -93,6 +93,7 @@ class SmartTubController: return data async def _get_spa_data(self, spa): + # pylint: disable=no-self-use full_status, reminders, errors = await asyncio.gather( spa.get_status_full(), spa.get_reminders(), diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 42858f69b39..713f2a7f7a1 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "dependencies": [], "codeowners": ["@mdz"], - "requirements": ["python-smarttub==0.0.25"], + "requirements": ["python-smarttub==0.0.27"], "quality_scale": "platinum", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/smarttub/translations/ca.json b/homeassistant/components/smarttub/translations/ca.json index d00c4d26c98..4f0e9a2c502 100644 --- a/homeassistant/components/smarttub/translations/ca.json +++ b/homeassistant/components/smarttub/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/smarttub/translations/fr.json b/homeassistant/components/smarttub/translations/fr.json index c660ca15e87..bb481048fff 100644 --- a/homeassistant/components/smarttub/translations/fr.json +++ b/homeassistant/components/smarttub/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "La r\u00e9-authentification a \u00e9t\u00e9 un succ\u00e8s" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide" @@ -10,7 +10,7 @@ "step": { "reauth_confirm": { "description": "L'int\u00e9gration SmartTub doit r\u00e9-authentifier votre compte", - "title": "R\u00e9authentification de l'int\u00e9gration" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/smarttub/translations/hu.json b/homeassistant/components/smarttub/translations/hu.json index e9a45d3773f..3764b27abd2 100644 --- a/homeassistant/components/smarttub/translations/hu.json +++ b/homeassistant/components/smarttub/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -17,7 +17,7 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, - "description": "Add meg SmartTub e-mail c\u00edmet \u00e9s jelsz\u00f3t a bejelentkez\u00e9shez", + "description": "Adja meg SmartTub e-mail c\u00edmet \u00e9s jelsz\u00f3t a bejelentkez\u00e9shez", "title": "Bejelentkez\u00e9s" } } diff --git a/homeassistant/components/smarttub/translations/id.json b/homeassistant/components/smarttub/translations/id.json index bf32b29d1e7..9b021f65771 100644 --- a/homeassistant/components/smarttub/translations/id.json +++ b/homeassistant/components/smarttub/translations/id.json @@ -9,6 +9,7 @@ }, "step": { "reauth_confirm": { + "description": "Integrasi SmartTub perlu mengautentikasi ulang akun Anda", "title": "Autentikasi Ulang Integrasi" }, "user": { diff --git a/homeassistant/components/smarttub/translations/ko.json b/homeassistant/components/smarttub/translations/ko.json index b68ff871d4d..ff50dadc63e 100644 --- a/homeassistant/components/smarttub/translations/ko.json +++ b/homeassistant/components/smarttub/translations/ko.json @@ -8,6 +8,9 @@ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { + "reauth_confirm": { + "title": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uc7ac\uc778\uc99d\ud558\uae30" + }, "user": { "data": { "email": "\uc774\uba54\uc77c", diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 3034580d5e0..b88b81d1fb4 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -24,16 +24,6 @@ class Gateway: async def init_async(self): """Initialize the sms gateway asynchronously.""" await self._worker.init_async() - try: - await self._worker.set_incoming_sms_async() - except gammu.ERR_NOTSUPPORTED: - _LOGGER.warning("Falling back to pulling method for SMS notifications") - except gammu.GSMError: - _LOGGER.warning( - "GSM error, falling back to pulling method for SMS notifications" - ) - else: - await self._worker.set_incoming_callback_async(self.sms_callback) def sms_pull(self, state_machine): """Pull device. @@ -47,21 +37,6 @@ class Gateway: self.sms_read_messages(state_machine, self._first_pull) self._first_pull = False - def sms_callback(self, state_machine, callback_type, callback_data): - """Receive notification about incoming event. - - @param state_machine: state machine which invoked action - @type state_machine: gammu.StateMachine - @param callback_type: type of action, one of Call, SMS, CB, USSD - @type callback_type: string - @param data: event data - @type data: hash - """ - _LOGGER.debug( - "Received incoming event type:%s,data:%s", callback_type, callback_data - ) - self.sms_read_messages(state_machine) - def sms_read_messages(self, state_machine, force=False): """Read all received SMS messages. diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 04964c15878..1bd3c60b9b9 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -46,7 +46,7 @@ class SMSNotificationService(BaseNotificationService): """Send SMS message.""" smsinfo = { "Class": -1, - "Unicode": False, + "Unicode": True, "Entries": [{"ID": "ConcatenatedTextLong", "Buffer": message}], } try: diff --git a/homeassistant/components/sms/translations/fr.json b/homeassistant/components/sms/translations/fr.json index b4c479cfd50..ebfa3c1da08 100644 --- a/homeassistant/components/sms/translations/fr.json +++ b/homeassistant/components/sms/translations/fr.json @@ -5,8 +5,8 @@ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "Echec de connexion", - "unknown": "Erreur inatendue" + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" }, "step": { "user": { diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index c9c7136fb94..644f1861c05 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -2,10 +2,7 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, -) +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -42,7 +39,7 @@ SENSOR_TYPES = [ json_key="lifeTimeData", name="Lifetime energy", icon="mdi:solar-power", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), diff --git a/homeassistant/components/solaredge/translations/fr.json b/homeassistant/components/solaredge/translations/fr.json index 638e19a2a03..36b283a9145 100644 --- a/homeassistant/components/solaredge/translations/fr.json +++ b/homeassistant/components/solaredge/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "could_not_connect": "Impossible de se connecter \u00e0 l'API solaredge", "invalid_api_key": "Cl\u00e9 API invalide", "site_not_active": "The site n'est pas actif" diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 9d162e919f4..d3607ecd29c 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -1,6 +1,9 @@ """Support for SolarEdge-local Monitoring API.""" +from __future__ import annotations + from contextlib import suppress -from copy import deepcopy +from copy import copy +from dataclasses import dataclass from datetime import timedelta import logging import statistics @@ -9,7 +12,11 @@ from requests.exceptions import ConnectTimeout, HTTPError from solaredge_local import SolarEdge 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_IP_ADDRESS, CONF_NAME, @@ -41,122 +48,134 @@ INVERTER_MODES = ( "IDLE", ) -# Supported sensor types: -# Key: ['json_key', 'name', unit, icon, attribute name] -SENSOR_TYPES = { - "current_AC_voltage": [ - "gridvoltage", - "Grid Voltage", - ELECTRIC_POTENTIAL_VOLT, - "mdi:current-ac", - None, - None, - ], - "current_DC_voltage": [ - "dcvoltage", - "DC Voltage", - ELECTRIC_POTENTIAL_VOLT, - "mdi:current-dc", - None, - None, - ], - "current_frequency": [ - "gridfrequency", - "Grid Frequency", - FREQUENCY_HERTZ, - "mdi:current-ac", - None, - None, - ], - "current_power": [ - "currentPower", - "Current Power", - POWER_WATT, - "mdi:solar-power", - None, - None, - ], - "energy_this_month": [ - "energyThisMonth", - "Energy This Month", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "energy_this_year": [ - "energyThisYear", - "Energy This Year", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "energy_today": [ - "energyToday", - "Energy Today", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "inverter_temperature": [ - "invertertemperature", - "Inverter Temperature", - TEMP_CELSIUS, - None, - "operating_mode", - DEVICE_CLASS_TEMPERATURE, - ], - "lifetime_energy": [ - "energyTotal", - "Lifetime Energy", - ENERGY_WATT_HOUR, - "mdi:solar-power", - None, - None, - ], - "optimizer_connected": [ - "optimizers", - "Optimizers Online", - "optimizers", - "mdi:solar-panel", - "optimizers_connected", - None, - ], - "optimizer_current": [ - "optimizercurrent", - "Average Optimizer Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:solar-panel", - None, - None, - ], - "optimizer_power": [ - "optimizerpower", - "Average Optimizer Power", - POWER_WATT, - "mdi:solar-panel", - None, - None, - ], - "optimizer_temperature": [ - "optimizertemperature", - "Average Optimizer Temperature", - TEMP_CELSIUS, - "mdi:solar-panel", - None, - DEVICE_CLASS_TEMPERATURE, - ], - "optimizer_voltage": [ - "optimizervoltage", - "Average Optimizer Voltage", - ELECTRIC_POTENTIAL_VOLT, - "mdi:solar-panel", - None, - None, - ], -} + +@dataclass +class SolarEdgeLocalSensorEntityDescription(SensorEntityDescription): + """Describes SolarEdge-local sensor entity.""" + + extra_attribute: str | None = None + + +SENSOR_TYPES: tuple[SolarEdgeLocalSensorEntityDescription, ...] = ( + SolarEdgeLocalSensorEntityDescription( + key="gridvoltage", + name="Grid Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:current-ac", + ), + SolarEdgeLocalSensorEntityDescription( + key="dcvoltage", + name="DC Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:current-dc", + ), + SolarEdgeLocalSensorEntityDescription( + key="gridfrequency", + name="Grid Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:current-ac", + ), + SolarEdgeLocalSensorEntityDescription( + key="currentPower", + name="Current Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyThisMonth", + name="Energy This Month", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyThisYear", + name="Energy This Year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyToday", + name="Energy Today", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="energyTotal", + name="Lifetime Energy", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:solar-power", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizers", + name="Optimizers Online", + native_unit_of_measurement="optimizers", + icon="mdi:solar-panel", + extra_attribute="optimizers_connected", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizercurrent", + name="Average Optimizer Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:solar-panel", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizerpower", + name="Average Optimizer Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:solar-panel", + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizertemperature", + name="Average Optimizer Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:solar-panel", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SolarEdgeLocalSensorEntityDescription( + key="optimizervoltage", + name="Average Optimizer Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:solar-panel", + ), +) + +SENSOR_TYPE_INVERTER_TEMPERATURE = SolarEdgeLocalSensorEntityDescription( + key="invertertemperature", + name="Inverter Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + extra_attribute="operating_mode", + device_class=DEVICE_CLASS_TEMPERATURE, +) + +SENSOR_TYPES_ENERGY_IMPORT: tuple[SolarEdgeLocalSensorEntityDescription, ...] = ( + SolarEdgeLocalSensorEntityDescription( + key="currentPowerimport", + name="current import Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-collapse-down", + ), + SolarEdgeLocalSensorEntityDescription( + key="totalEnergyimport", + name="total import Energy", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:counter", + ), +) + +SENSOR_TYPES_ENERGY_EXPORT: tuple[SolarEdgeLocalSensorEntityDescription, ...] = ( + SolarEdgeLocalSensorEntityDescription( + key="currentPowerexport", + name="current export Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-expand-up", + ), + SolarEdgeLocalSensorEntityDescription( + key="totalEnergyexport", + name="total export Energy", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:counter", + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -188,133 +207,76 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Could not retrieve details from SolarEdge API") return + # Create solaredge data service which will retrieve and update the data. + data = SolarEdgeData(hass, api) + # Changing inverter temperature unit. - sensors = deepcopy(SENSOR_TYPES) + inverter_temp_description = copy(SENSOR_TYPE_INVERTER_TEMPERATURE) if status.inverters.primary.temperature.units.farenheit: - sensors["inverter_temperature"] = [ - "invertertemperature", - "Inverter Temperature", - TEMP_FAHRENHEIT, - "mdi:thermometer", - "operating_mode", - DEVICE_CLASS_TEMPERATURE, - ] + inverter_temp_description.native_unit_of_measurement = TEMP_FAHRENHEIT + + # Create entities + entities = [ + SolarEdgeSensor(platform_name, data, description) + for description in (*SENSOR_TYPES, inverter_temp_description) + ] try: if status.metersList[0]: - sensors["import_current_power"] = [ - "currentPowerimport", - "current import Power", - POWER_WATT, - "mdi:arrow-collapse-down", - None, - None, - ] - sensors["import_meter_reading"] = [ - "totalEnergyimport", - "total import Energy", - ENERGY_WATT_HOUR, - "mdi:counter", - None, - None, - ] + entities.extend( + [ + SolarEdgeSensor(platform_name, data, description) + for description in SENSOR_TYPES_ENERGY_IMPORT + ] + ) except IndexError: _LOGGER.debug("Import meter sensors are not created") try: if status.metersList[1]: - sensors["export_current_power"] = [ - "currentPowerexport", - "current export Power", - POWER_WATT, - "mdi:arrow-expand-up", - None, - None, - ] - sensors["export_meter_reading"] = [ - "totalEnergyexport", - "total export Energy", - ENERGY_WATT_HOUR, - "mdi:counter", - None, - None, - ] + entities.extend( + [ + SolarEdgeSensor(platform_name, data, description) + for description in SENSOR_TYPES_ENERGY_EXPORT + ] + ) except IndexError: _LOGGER.debug("Export meter sensors are not created") - # Create solaredge data service which will retrieve and update the data. - data = SolarEdgeData(hass, api) - - # Create a new sensor for each sensor type. - entities = [] - for sensor_info in sensors.values(): - sensor = SolarEdgeSensor( - platform_name, - data, - sensor_info[0], - sensor_info[1], - sensor_info[2], - sensor_info[3], - sensor_info[4], - sensor_info[5], - ) - entities.append(sensor) - add_entities(entities, True) class SolarEdgeSensor(SensorEntity): """Representation of an SolarEdge Monitoring API sensor.""" + entity_description: SolarEdgeLocalSensorEntityDescription + def __init__( - self, platform_name, data, json_key, name, unit, icon, attr, device_class + self, + platform_name, + data, + description: SolarEdgeLocalSensorEntityDescription, ): """Initialize the sensor.""" + self.entity_description = description self._platform_name = platform_name self._data = data - self._state = None - - self._json_key = json_key - self._name = name - self._unit_of_measurement = unit - self._icon = icon - self._attr = attr - self._attr_device_class = device_class - - @property - def name(self): - """Return the name.""" - return f"{self._platform_name} ({self._name})" - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self._attr_name = f"{platform_name} ({description.name})" @property def extra_state_attributes(self): """Return the state attributes.""" - if self._attr: + if extra_attr := self.entity_description.extra_attribute: try: - return {self._attr: self._data.info[self._json_key]} + return {extra_attr: self._data.info[self.entity_description.key]} except KeyError: - return None + pass return None - @property - def icon(self): - """Return the sensor icon.""" - return self._icon - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Get the latest data from the sensor and update the state.""" self._data.update() - self._state = self._data.data[self._json_key] + self._attr_native_value = self._data.data[self.entity_description.key] class SolarEdgeData: diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index e32f1d85564..190898abb27 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -54,49 +54,18 @@ class SolarlogData(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self): """Update the data from the SolarLog device.""" try: - api = await self.hass.async_add_executor_job(SolarLog, self.host) + data = await self.hass.async_add_executor_job(SolarLog, self.host) except (OSError, Timeout, HTTPError) as err: raise update_coordinator.UpdateFailed(err) - if api.time.year == 1999: + if data.time.year == 1999: raise update_coordinator.UpdateFailed( "Invalid data returned (can happen after Solarlog restart)." ) self.logger.debug( "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", - api.time, + data.time, ) - data = {} - - try: - data["TIME"] = api.time - data["powerAC"] = api.power_ac - data["powerDC"] = api.power_dc - data["voltageAC"] = api.voltage_ac - data["voltageDC"] = api.voltage_dc - data["yieldDAY"] = api.yield_day / 1000 - data["yieldYESTERDAY"] = api.yield_yesterday / 1000 - data["yieldMONTH"] = api.yield_month / 1000 - data["yieldYEAR"] = api.yield_year / 1000 - data["yieldTOTAL"] = api.yield_total / 1000 - data["consumptionAC"] = api.consumption_ac - data["consumptionDAY"] = api.consumption_day / 1000 - data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000 - data["consumptionMONTH"] = api.consumption_month / 1000 - data["consumptionYEAR"] = api.consumption_year / 1000 - data["consumptionTOTAL"] = api.consumption_total / 1000 - data["totalPOWER"] = api.total_power - data["alternatorLOSS"] = api.alternator_loss - data["CAPACITY"] = round(api.capacity * 100, 0) - data["EFFICIENCY"] = round(api.efficiency * 100, 0) - data["powerAVAILABLE"] = api.power_available - data["USAGE"] = round(api.usage * 100, 0) - except AttributeError as err: - raise update_coordinator.UpdateFailed( - f"Missing details data in Solarlog response: {err}" - ) from err - - _LOGGER.debug("Updated Solarlog overview data: %s", data) return data diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index eecf73b6a09..0e9e5e8e5e0 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, SensorEntityDescription, ) from homeassistant.const import ( @@ -28,29 +28,20 @@ DEFAULT_NAME = "solarlog" @dataclass -class SolarlogRequiredKeysMixin: - """Mixin for required keys.""" - - json_key: str - - -@dataclass -class SolarLogSensorEntityDescription( - SensorEntityDescription, SolarlogRequiredKeysMixin -): +class SolarLogSensorEntityDescription(SensorEntityDescription): """Describes Solarlog sensor entity.""" + factor: float | None = None + SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( SolarLogSensorEntityDescription( key="time", - json_key="TIME", name="last update", device_class=DEVICE_CLASS_TIMESTAMP, ), SolarLogSensorEntityDescription( key="power_ac", - json_key="powerAC", name="power AC", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -58,7 +49,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="power_dc", - json_key="powerDC", name="power DC", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -66,7 +56,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="voltage_ac", - json_key="voltageAC", name="voltage AC", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, @@ -74,7 +63,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="voltage_dc", - json_key="voltageDC", name="voltage DC", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, @@ -82,43 +70,42 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="yield_day", - json_key="yieldDAY", name="yield day", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_yesterday", - json_key="yieldYESTERDAY", name="yield yesterday", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_month", - json_key="yieldMONTH", name="yield month", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_year", - json_key="yieldYEAR", name="yield year", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + factor=0.001, ), SolarLogSensorEntityDescription( key="yield_total", - json_key="yieldTOTAL", name="yield total", icon="mdi:solar-power", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_ac", - json_key="consumptionAC", name="consumption AC", native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, @@ -126,43 +113,42 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="consumption_day", - json_key="consumptionDAY", name="consumption day", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_yesterday", - json_key="consumptionYESTERDAY", name="consumption yesterday", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_month", - json_key="consumptionMONTH", name="consumption month", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_year", - json_key="consumptionYEAR", name="consumption year", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, + factor=0.001, ), SolarLogSensorEntityDescription( key="consumption_total", - json_key="consumptionTOTAL", name="consumption total", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=STATE_CLASS_TOTAL, + factor=0.001, ), SolarLogSensorEntityDescription( key="total_power", - json_key="totalPOWER", name="installed peak power", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -170,7 +156,6 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="alternator_loss", - json_key="alternatorLOSS", name="alternator loss", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -179,24 +164,23 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="capacity", - json_key="CAPACITY", name="capacity", icon="mdi:solar-power", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, + factor=100, ), SolarLogSensorEntityDescription( key="efficiency", - json_key="EFFICIENCY", name="efficiency", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, + factor=100, ), SolarLogSensorEntityDescription( key="power_available", - json_key="powerAVAILABLE", name="power available", icon="mdi:solar-power", native_unit_of_measurement=POWER_WATT, @@ -205,10 +189,10 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( ), SolarLogSensorEntityDescription( key="usage", - json_key="USAGE", name="usage", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, + factor=100, ), ) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index ee7425cf2d7..5918c397a7b 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -39,4 +39,9 @@ class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the native sensor value.""" - return self.coordinator.data[self.entity_description.json_key] + result = getattr(self.coordinator.data, self.entity_description.key) + if self.entity_description.factor: + state = round(result * self.entity_description.factor, 3) + else: + state = result + return state diff --git a/homeassistant/components/solarlog/translations/fr.json b/homeassistant/components/solarlog/translations/fr.json index b327f58adf5..b65ca3b05e7 100644 --- a/homeassistant/components/solarlog/translations/fr.json +++ b/homeassistant/components/solarlog/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de la connexion" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/solarlog/translations/hu.json b/homeassistant/components/solarlog/translations/hu.json index 23baa393942..ada2ab95751 100644 --- a/homeassistant/components/solarlog/translations/hu.json +++ b/homeassistant/components/solarlog/translations/hu.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "A Solar-Log szenzorokhoz haszn\u00e1land\u00f3 el\u0151tag" }, "title": "Hat\u00e1rozza meg a Solar-Log kapcsolatot" diff --git a/homeassistant/components/soma/translations/hu.json b/homeassistant/components/soma/translations/hu.json index c3e572ebe0a..e7ac9d8d71c 100644 --- a/homeassistant/components/soma/translations/hu.json +++ b/homeassistant/components/soma/translations/hu.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" }, "description": "K\u00e9rj\u00fck, adja meg a SOMA Connect csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sait.", diff --git a/homeassistant/components/somfy/translations/fr.json b/homeassistant/components/somfy/translations/fr.json index c8783effc28..08b978e3f12 100644 --- a/homeassistant/components/somfy/translations/fr.json +++ b/homeassistant/components/somfy/translations/fr.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration d'url autoriser.", - "missing_configuration": "Le composant Somfy n'est pas configur\u00e9. Veuillez suivre la documentation.", + "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.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "create_entry": { - "default": "Authentifi\u00e9 avec succ\u00e8s avec Somfy." + "default": "Authentification r\u00e9ussie" }, "step": { "pick_implementation": { - "title": "Choisir la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" } } } diff --git a/homeassistant/components/somfy/translations/hu.json b/homeassistant/components/somfy/translations/hu.json index ce4e94b3399..06b0894faf1 100644 --- a/homeassistant/components/somfy/translations/hu.json +++ b/homeassistant/components/somfy/translations/hu.json @@ -2,7 +2,7 @@ "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\u00e9rem, 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.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/somfy_mylink/translations/fr.json b/homeassistant/components/somfy_mylink/translations/fr.json index bee2ea3ba13..f31a3b9d86f 100644 --- a/homeassistant/components/somfy_mylink/translations/fr.json +++ b/homeassistant/components/somfy_mylink/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 " + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de la connexion ", - "invalid_auth": "Authentification invalide ", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "flow_title": "Somfy MyLink {mac} ( {ip} )", @@ -22,7 +22,7 @@ }, "options": { "abort": { - "cannot_connect": "Echec de connection" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "entity_config": { diff --git a/homeassistant/components/somfy_mylink/translations/hu.json b/homeassistant/components/somfy_mylink/translations/hu.json index 3610a930022..fa6620859f5 100644 --- a/homeassistant/components/somfy_mylink/translations/hu.json +++ b/homeassistant/components/somfy_mylink/translations/hu.json @@ -12,7 +12,7 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "system_id": "Rendszerazonos\u00edt\u00f3" }, diff --git a/homeassistant/components/somfy_mylink/translations/id.json b/homeassistant/components/somfy_mylink/translations/id.json index 0203ae421e2..c4b2269ef2c 100644 --- a/homeassistant/components/somfy_mylink/translations/id.json +++ b/homeassistant/components/somfy_mylink/translations/id.json @@ -8,7 +8,7 @@ "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Somfy MyLink {mac} ({ip})", + "flow_title": "{mac} ({ip})", "step": { "user": { "data": { diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index cc35a8db4af..f226d1883a5 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -35,7 +35,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: +async def validate_input(hass: HomeAssistant, data: dict) -> None: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -54,8 +54,6 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: await sonarr.update() - return True - class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Sonarr.""" @@ -72,14 +70,14 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SonarrOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> FlowResult: """Confirm reauth dialog.""" if user_input is None: @@ -164,7 +162,7 @@ class SonarrOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict[str, Any] | None = None): + async def async_step_init(self, user_input: dict[str, int] | 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 3f5ef275fef..8911927d732 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -7,11 +7,12 @@ from typing import Any from sonarr import Sonarr, SonarrConnectionError, SonarrError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_GIGABYTES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DATA_SONARR, DOMAIN @@ -19,6 +20,50 @@ from .entity import SonarrEntity _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="commands", + name="Sonarr Commands", + icon="mdi:code-braces", + native_unit_of_measurement="Commands", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="diskspace", + name="Sonarr Disk Space", + icon="mdi:harddisk", + native_unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="queue", + name="Sonarr Queue", + icon="mdi:download", + native_unit_of_measurement="Episodes", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="series", + name="Sonarr Shows", + icon="mdi:television", + native_unit_of_measurement="Series", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="upcoming", + name="Sonarr Upcoming", + icon="mdi:television", + native_unit_of_measurement="Episodes", + ), + SensorEntityDescription( + key="wanted", + name="Sonarr Wanted", + icon="mdi:television", + native_unit_of_measurement="Episodes", + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -26,18 +71,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" - options = entry.options - sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] + sonarr: Sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] + options: dict[str, Any] = dict(entry.options) entities = [ - SonarrCommandsSensor(sonarr, entry.entry_id), - SonarrDiskspaceSensor(sonarr, entry.entry_id), - SonarrQueueSensor(sonarr, entry.entry_id), - SonarrSeriesSensor(sonarr, entry.entry_id), - SonarrUpcomingSensor(sonarr, entry.entry_id, days=options[CONF_UPCOMING_DAYS]), - SonarrWantedSensor( - sonarr, entry.entry_id, max_items=options[CONF_WANTED_MAX_ITEMS] - ), + SonarrSensor(sonarr, entry.entry_id, description, options) + for description in SENSOR_TYPES ] async_add_entities(entities, True) @@ -71,23 +110,19 @@ class SonarrSensor(SonarrEntity, SensorEntity): def __init__( self, - *, sonarr: Sonarr, entry_id: str, - enabled_default: bool = True, - icon: str, - key: str, - name: str, - unit_of_measurement: str | None = None, + description: SensorEntityDescription, + options: dict[str, Any], ) -> None: """Initialize Sonarr sensor.""" - self._key = key - self._attr_name = name - self._attr_icon = icon - self._attr_unique_id = f"{entry_id}_{key}" - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_entity_registry_enabled_default = enabled_default - self.last_update_success = False + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + + self.data: dict[str, Any] = {} + self.last_update_success: bool = False + self.upcoming_days: int = options[CONF_UPCOMING_DAYS] + self.wanted_max_items: int = options[CONF_WANTED_MAX_ITEMS] super().__init__( sonarr=sonarr, @@ -100,253 +135,90 @@ class SonarrSensor(SonarrEntity, SensorEntity): """Return sensor availability.""" return self.last_update_success - -class SonarrCommandsSensor(SonarrSensor): - """Defines a Sonarr Commands sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Commands sensor.""" - self._commands = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:code-braces", - key="commands", - name=f"{sonarr.app.info.app_name} Commands", - unit_of_measurement="Commands", - enabled_default=False, - ) - @sonarr_exception_handler async def async_update(self) -> None: """Update entity.""" - self._commands = await self.sonarr.commands() + key = self.entity_description.key + + if key == "diskspace": + await self.sonarr.update() + elif key == "commands": + self.data[key] = await self.sonarr.commands() + elif key == "queue": + self.data[key] = await self.sonarr.queue() + elif key == "series": + self.data[key] = await self.sonarr.series() + elif key == "upcoming": + local = dt_util.start_of_local_day().replace(microsecond=0) + start = dt_util.as_utc(local) + end = start + timedelta(days=self.upcoming_days) + + self.data[key] = await self.sonarr.calendar( + start=start.isoformat(), end=end.isoformat() + ) + elif key == "wanted": + self.data[key] = await self.sonarr.wanted(page_size=self.wanted_max_items) @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the entity.""" attrs = {} + key = self.entity_description.key - for command in self._commands: - attrs[command.name] = command.state + if key == "diskspace": + for disk in self.sonarr.app.disks: + free = disk.free / 1024 ** 3 + total = disk.total / 1024 ** 3 + usage = free / total * 100 - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._commands) - - -class SonarrDiskspaceSensor(SonarrSensor): - """Defines a Sonarr Disk Space sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Disk Space sensor.""" - self._disks = [] - self._total_free = 0 - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:harddisk", - key="diskspace", - name=f"{sonarr.app.info.app_name} Disk Space", - unit_of_measurement=DATA_GIGABYTES, - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - app = await self.sonarr.update() - self._disks = app.disks - self._total_free = sum(disk.free for disk in self._disks) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for disk in self._disks: - free = disk.free / 1024 ** 3 - total = disk.total / 1024 ** 3 - usage = free / total * 100 - - attrs[ - disk.path - ] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)" - - return attrs - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - free = self._total_free / 1024 ** 3 - return f"{free:.2f}" - - -class SonarrQueueSensor(SonarrSensor): - """Defines a Sonarr Queue sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Queue sensor.""" - self._queue = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:download", - key="queue", - name=f"{sonarr.app.info.app_name} Queue", - unit_of_measurement="Episodes", - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - self._queue = await self.sonarr.queue() - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for item in self._queue: - remaining = 1 if item.size == 0 else item.size_remaining / item.size - remaining_pct = 100 * (1 - remaining) - name = f"{item.episode.series.title} {item.episode.identifier}" - attrs[name] = f"{remaining_pct:.2f}%" - - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._queue) - - -class SonarrSeriesSensor(SonarrSensor): - """Defines a Sonarr Series sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str) -> None: - """Initialize Sonarr Series sensor.""" - self._items = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:television", - key="series", - name=f"{sonarr.app.info.app_name} Shows", - unit_of_measurement="Series", - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - self._items = await self.sonarr.series() - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for item in self._items: - attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes" - - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._items) - - -class SonarrUpcomingSensor(SonarrSensor): - """Defines a Sonarr Upcoming sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str, days: int = 1) -> None: - """Initialize Sonarr Upcoming sensor.""" - self._days = days - self._upcoming = [] - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:television", - key="upcoming", - name=f"{sonarr.app.info.app_name} Upcoming", - unit_of_measurement="Episodes", - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - local = dt_util.start_of_local_day().replace(microsecond=0) - start = dt_util.as_utc(local) - end = start + timedelta(days=self._days) - self._upcoming = await self.sonarr.calendar( - start=start.isoformat(), end=end.isoformat() - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - attrs = {} - - for episode in self._upcoming: - attrs[episode.series.title] = episode.identifier - - return attrs - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return len(self._upcoming) - - -class SonarrWantedSensor(SonarrSensor): - """Defines a Sonarr Wanted sensor.""" - - def __init__(self, sonarr: Sonarr, entry_id: str, max_items: int = 10) -> None: - """Initialize Sonarr Wanted sensor.""" - self._max_items = max_items - self._results = None - self._total: int | None = None - - super().__init__( - sonarr=sonarr, - entry_id=entry_id, - icon="mdi:television", - key="wanted", - name=f"{sonarr.app.info.app_name} Wanted", - unit_of_measurement="Episodes", - enabled_default=False, - ) - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - self._results = await self.sonarr.wanted(page_size=self._max_items) - self._total = self._results.total - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - attrs = {} - - if self._results is not None: - for episode in self._results.episodes: + attrs[ + disk.path + ] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)" + elif key == "commands" and self.data.get(key) is not None: + for command in self.data[key]: + attrs[command.name] = command.state + elif key == "queue" and self.data.get(key) is not None: + for item in self.data[key]: + remaining = 1 if item.size == 0 else item.size_remaining / item.size + remaining_pct = 100 * (1 - remaining) + name = f"{item.episode.series.title} {item.episode.identifier}" + attrs[name] = f"{remaining_pct:.2f}%" + elif key == "series" and self.data.get(key) is not None: + for item in self.data[key]: + attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes" + elif key == "upcoming" and self.data.get(key) is not None: + for episode in self.data[key]: + attrs[episode.series.title] = episode.identifier + elif key == "wanted" and self.data.get(key) is not None: + for episode in self.data[key].episodes: name = f"{episode.series.title} {episode.identifier}" attrs[name] = episode.airdate return attrs @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._total + key = self.entity_description.key + + if key == "diskspace": + total_free = sum(disk.free for disk in self.sonarr.app.disks) + free = total_free / 1024 ** 3 + return f"{free:.2f}" + + if key == "commands" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "queue" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "series" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "upcoming" and self.data.get(key) is not None: + return len(self.data[key]) + + if key == "wanted" and self.data.get(key) is not None: + return self.data[key].total + + return None diff --git a/homeassistant/components/sonarr/translations/fr.json b/homeassistant/components/sonarr/translations/fr.json index 6fa91de98a4..154d3435da3 100644 --- a/homeassistant/components/sonarr/translations/fr.json +++ b/homeassistant/components/sonarr/translations/fr.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", - "unknown": "Erreur innatendue" + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -13,16 +13,16 @@ "step": { "reauth_confirm": { "description": "L'int\u00e9gration Sonarr doit \u00eatre r\u00e9-authentifi\u00e9e manuellement avec l'API Sonarr h\u00e9berg\u00e9e sur: {host}", - "title": "R\u00e9-authentifier avec Sonarr" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { - "api_key": "Cl\u00e9 API", + "api_key": "Cl\u00e9 d'API", "base_path": "Chemin vers l'API", "host": "H\u00f4te", "port": "Port", - "ssl": "Sonarr utilise un certificat SSL", - "verify_ssl": "Sonarr utilise un certificat appropri\u00e9" + "ssl": "Utilise un certificat SSL", + "verify_ssl": "V\u00e9rifier le certificat SSL" } } } diff --git a/homeassistant/components/sonarr/translations/hu.json b/homeassistant/components/sonarr/translations/hu.json index 6ebdb22404c..7ac0b621b13 100644 --- a/homeassistant/components/sonarr/translations/hu.json +++ b/homeassistant/components/sonarr/translations/hu.json @@ -19,7 +19,7 @@ "data": { "api_key": "API kulcs", "base_path": "El\u00e9r\u00e9si \u00fat az API-hoz", - "host": "Hoszt", + "host": "C\u00edm", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" diff --git a/homeassistant/components/sonarr/translations/id.json b/homeassistant/components/sonarr/translations/id.json index ffaf1d22604..9d906a07f91 100644 --- a/homeassistant/components/sonarr/translations/id.json +++ b/homeassistant/components/sonarr/translations/id.json @@ -9,7 +9,7 @@ "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, - "flow_title": "Sonarr: {name}", + "flow_title": "{name}", "step": { "reauth_confirm": { "description": "Integrasi Sonarr perlu diautentikasi ulang secara manual dengan API Sonarr yang dihosting di: {host}", diff --git a/homeassistant/components/songpal/translations/fr.json b/homeassistant/components/songpal/translations/fr.json index 5975bb955fa..84f115ec1a5 100644 --- a/homeassistant/components/songpal/translations/fr.json +++ b/homeassistant/components/songpal/translations/fr.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "not_songpal_device": "Pas un appareil Songpal" }, "error": { - "cannot_connect": "Echec de connexion" + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "Sony Songpal {name} ({host})", "step": { diff --git a/homeassistant/components/songpal/translations/hu.json b/homeassistant/components/songpal/translations/hu.json index 2bce32d0cb8..a02844a50a7 100644 --- a/homeassistant/components/songpal/translations/hu.json +++ b/homeassistant/components/songpal/translations/hu.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "init": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/songpal/translations/id.json b/homeassistant/components/songpal/translations/id.json index 2b8149661bc..9e619e5bf76 100644 --- a/homeassistant/components/songpal/translations/id.json +++ b/homeassistant/components/songpal/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "Sony Songpal {name} ({host})", + "flow_title": "{name} ({host})", "step": { "init": { "description": "Ingin menyiapkan {name} ({host})?" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ae3652683d4..aafcba744ea 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -90,6 +90,7 @@ class SonosData: self.discovery_ignored: set[str] = set() self.discovery_known: set[str] = set() self.boot_counts: dict[str, int] = {} + self.mdns_names: dict[str, str] = {} async def async_setup(hass, config): @@ -263,20 +264,22 @@ class SonosDiscoveryManager: else: async_dispatcher_send(self.hass, f"{SONOS_SEEN}-{uid}") - @callback - def _async_ssdp_discovered_player(self, info): + async def _async_ssdp_discovered_player(self, info, change): + if change == ssdp.SsdpChange.BYEBYE: + return + discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname boot_seqnum = info.get("X-RINCON-BOOTSEQ") uid = info.get(ssdp.ATTR_UPNP_UDN) if uid.startswith("uuid:"): uid = uid[5:] self.async_discovered_player( - "SSDP", info, discovered_ip, uid, boot_seqnum, info.get("modelName") + "SSDP", info, discovered_ip, uid, boot_seqnum, info.get("modelName"), None ) @callback def async_discovered_player( - self, source, info, discovered_ip, uid, boot_seqnum, model + self, source, info, discovered_ip, uid, boot_seqnum, model, mdns_name ): """Handle discovery via ssdp or zeroconf.""" if model in DISCOVERY_IGNORED_MODELS: @@ -285,6 +288,9 @@ class SonosDiscoveryManager: if boot_seqnum: boot_seqnum = int(boot_seqnum) self.data.boot_counts.setdefault(uid, boot_seqnum) + if mdns_name: + self.data.mdns_names[uid] = mdns_name + if uid not in self.data.discovery_known: _LOGGER.debug("New %s discovery uid=%s: %s", source, uid, info) self.data.discovery_known.add(uid) @@ -316,7 +322,7 @@ class SonosDiscoveryManager: return self.entry.async_on_unload( - ssdp.async_register_callback( + await ssdp.async_register_callback( self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} ) ) diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index 215e4fede32..73149c6a286 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -6,9 +6,10 @@ import logging from typing import Any from soco import SoCo -from soco.alarms import Alarm, get_alarms +from soco.alarms import Alarm, Alarms from soco.exceptions import SoCoException +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM @@ -23,48 +24,76 @@ class SonosAlarms(SonosHouseholdCoordinator): def __init__(self, *args: Any) -> None: """Initialize the data.""" super().__init__(*args) - self._alarms: dict[str, Alarm] = {} + self.alarms: Alarms = Alarms() + self.created_alarm_ids: set[str] = set() def __iter__(self) -> Iterator: """Return an iterator for the known alarms.""" - alarms = list(self._alarms.values()) - return iter(alarms) + return iter(self.alarms) def get(self, alarm_id: str) -> Alarm | None: """Get an Alarm instance.""" - return self._alarms.get(alarm_id) + return self.alarms.get(alarm_id) - async def async_update_entities(self, soco: SoCo) -> bool: + async def async_update_entities( + self, soco: SoCo, update_id: int | None = None + ) -> None: """Create and update alarms entities, return success.""" + updated = await self.hass.async_add_executor_job( + self.update_cache, soco, update_id + ) + if not updated: + return + + for alarm_id, alarm in self.alarms.alarms.items(): + if alarm_id in self.created_alarm_ids: + continue + speaker = self.hass.data[DATA_SONOS].discovered.get(alarm.zone.uid) + if speaker: + async_dispatcher_send( + self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id] + ) + async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}") + + @callback + def async_handle_event(self, event_id: str, soco: SoCo) -> None: + """Create a task to update from an event callback.""" + _, event_id = event_id.split(":") + event_id = int(event_id) + self.hass.async_create_task(self.async_process_event(event_id, soco)) + + async def async_process_event(self, event_id: str, soco: SoCo) -> None: + """Process the event payload in an async lock and update entities.""" + async with self.cache_update_lock: + if event_id <= self.last_processed_event_id: + # Skip updates if this event_id has already been seen + return + await self.async_update_entities(soco, event_id) + + def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: + """Update cache of known alarms and return if cache has changed.""" try: - new_alarms = await self.hass.async_add_executor_job(self.update_cache, soco) + self.alarms.update(soco) except (OSError, SoCoException) as err: - _LOGGER.error("Could not refresh alarms using %s: %s", soco, err) + _LOGGER.error("Could not update alarms using %s: %s", soco, err) return False - for alarm in new_alarms: - speaker = self.hass.data[DATA_SONOS].discovered[alarm.zone.uid] - async_dispatcher_send( - self.hass, SONOS_CREATE_ALARM, speaker, [alarm.alarm_id] - ) - async_dispatcher_send(self.hass, f"{SONOS_ALARMS_UPDATED}-{self.household_id}") + if update_id and self.alarms.last_id < update_id: + # Skip updates if latest query result is outdated or lagging + return False + + if ( + self.last_processed_event_id + and self.alarms.last_id <= self.last_processed_event_id + ): + # Skip updates already processed + return False + + _LOGGER.debug( + "Updating processed event %s from %s (was %s)", + self.alarms.last_id, + soco, + self.last_processed_event_id, + ) + self.last_processed_event_id = self.alarms.last_id return True - - def update_cache(self, soco: SoCo) -> set[Alarm]: - """Populate cache of known alarms. - - Prune deleted alarms and return new alarms. - """ - soco_alarms = get_alarms(soco) - new_alarms = set() - - for alarm in soco_alarms: - if alarm.alarm_id not in self._alarms: - new_alarms.add(alarm) - self._alarms[alarm.alarm_id] = alarm - - for alarm_id, alarm in list(self._alarms.items()): - if alarm not in soco_alarms: - self._alarms.pop(alarm_id) - - return new_alarms diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 3bbdf2d9a26..3fa3bbb8fa8 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -4,7 +4,7 @@ import logging import soco from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler @@ -38,13 +38,14 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler): return self.async_abort(reason="not_sonos_device") await self.async_set_unique_id(self._domain, raise_on_progress=False) host = discovery_info[CONF_HOST] + mdns_name = discovery_info[CONF_NAME] properties = discovery_info["properties"] boot_seqnum = properties.get("bootseq") model = properties.get("model") uid = hostname_to_uid(hostname) if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): discovery_manager.async_discovered_player( - "Zeroconf", properties, host, uid, boot_seqnum, model + "Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name ) return await self.async_step_discovery(discovery_info) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index dadec82a939..5730679dbd9 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -95,7 +95,10 @@ class SonosEntity(Entity): "name": self.speaker.zone_name, "model": self.speaker.model_name.replace("Sonos ", ""), "sw_version": self.speaker.version, - "connections": {(dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address)}, + "connections": { + (dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address), + (dr.CONNECTION_UPNP, f"uuid:{self.speaker.uid}"), + }, "manufacturer": "Sonos", "suggested_area": self.speaker.zone_name, } diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 9695265b24d..adf31b0f507 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -3,12 +3,14 @@ from __future__ import annotations from collections.abc import Iterator import logging +import re from typing import Any from soco import SoCo from soco.data_structures import DidlFavorite from soco.exceptions import SoCoException +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import SONOS_FAVORITES_UPDATED @@ -24,30 +26,87 @@ class SonosFavorites(SonosHouseholdCoordinator): """Initialize the data.""" super().__init__(*args) self._favorites: list[DidlFavorite] = [] + self.last_polled_ids: dict[str, int] = {} def __iter__(self) -> Iterator: """Return an iterator for the known favorites.""" favorites = self._favorites.copy() return iter(favorites) - async def async_update_entities(self, soco: SoCo) -> bool: + async def async_update_entities( + self, soco: SoCo, update_id: int | None = None + ) -> None: """Update the cache and update entities.""" - try: - await self.hass.async_add_executor_job(self.update_cache, soco) - except (OSError, SoCoException) as err: - _LOGGER.warning("Error requesting favorites from %s: %s", soco, err) - return False + updated = await self.hass.async_add_executor_job( + self.update_cache, soco, update_id + ) + if not updated: + return async_dispatcher_send( self.hass, f"{SONOS_FAVORITES_UPDATED}-{self.household_id}" ) - return True - def update_cache(self, soco: SoCo) -> None: - """Request new Sonos favorites from a speaker.""" + @callback + def async_handle_event(self, event_id: str, container_ids: str, soco: SoCo) -> None: + """Create a task to update from an event callback.""" + if not (match := re.search(r"FV:2,(\d+)", container_ids)): + return + + container_id = int(match.groups()[0]) + event_id = int(event_id.split(",")[-1]) + + self.hass.async_create_task( + self.async_process_event(event_id, container_id, soco) + ) + + async def async_process_event( + self, event_id: int, container_id: int, soco: SoCo + ) -> None: + """Process the event payload in an async lock and update entities.""" + async with self.cache_update_lock: + last_poll_id = self.last_polled_ids.get(soco.uid) + if ( + self.last_processed_event_id + and event_id <= self.last_processed_event_id + ): + # Skip updates if this event_id has already been seen + if not last_poll_id: + self.last_polled_ids[soco.uid] = container_id + return + + if last_poll_id and container_id <= last_poll_id: + return + + _LOGGER.debug( + "New favorites event %s from %s (was %s)", + event_id, + soco, + self.last_processed_event_id, + ) + self.last_processed_event_id = event_id + await self.async_update_entities(soco, container_id) + + def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: + """Update cache of known favorites and return if cache has changed.""" new_favorites = soco.music_library.get_sonos_favorites() - self._favorites = [] + # Polled update_id values do not match event_id values + # Each speaker can return a different polled update_id + last_poll_id = self.last_polled_ids.get(soco.uid) + if last_poll_id and new_favorites.update_id <= last_poll_id: + # Skip updates already processed + return False + self.last_polled_ids[soco.uid] = new_favorites.update_id + + _LOGGER.debug( + "Processing favorites update_id %s for %s (was: %s)", + new_favorites.update_id, + soco, + last_poll_id, + ) + + self._favorites = [] for fav in new_favorites: try: # exclude non-playable favorites with no linked resources @@ -58,7 +117,9 @@ class SonosFavorites(SonosHouseholdCoordinator): _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) _LOGGER.debug( - "Cached %s favorites for household %s", + "Cached %s favorites for household %s using %s", len(self._favorites), self.household_id, + soco, ) + return True diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 0854361cd79..01a75eb7747 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -1,9 +1,10 @@ """Helper methods for common tasks.""" from __future__ import annotations +from collections.abc import Callable import functools as ft import logging -from typing import Any, Callable +from typing import Any from soco.exceptions import SoCoException, SoCoUPnPException @@ -41,16 +42,6 @@ def soco_error(errorcodes: list[str] | None = None) -> Callable: return decorator -def uid_to_short_hostname(uid: str) -> str: - """Convert a Sonos uid to a short hostname.""" - hostname_uid = uid - if hostname_uid.startswith(UID_PREFIX): - hostname_uid = hostname_uid[len(UID_PREFIX) :] - if hostname_uid.endswith(UID_POSTFIX): - hostname_uid = hostname_uid[: -len(UID_POSTFIX)] - return f"Sonos-{hostname_uid}" - - def hostname_to_uid(hostname: str) -> str: """Convert a Sonos hostname to a uid.""" baseuid = hostname.split("-")[1].replace(".local.", "") diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index da964e93984..f233b338279 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -1,14 +1,14 @@ """Class representing a Sonos household storage helper.""" from __future__ import annotations -from collections import deque +import asyncio from collections.abc import Callable, Coroutine import logging -from typing import Any from soco import SoCo +from soco.exceptions import SoCoException -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from .const import DATA_SONOS @@ -23,19 +23,18 @@ class SonosHouseholdCoordinator: """Initialize the data.""" self.hass = hass self.household_id = household_id - self._processed_events = deque(maxlen=5) self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None + self.last_processed_event_id: int | None = None + self.cache_update_lock: asyncio.Lock | None = None def setup(self, soco: SoCo) -> None: """Set up the SonosAlarm instance.""" self.update_cache(soco) - self.hass.add_job(self._async_create_polling_debouncer) + self.hass.add_job(self._async_setup) - async def _async_create_polling_debouncer(self) -> None: - """Create a polling debouncer in async context. - - Used to ensure redundant poll requests from all speakers are coalesced. - """ + async def _async_setup(self) -> None: + """Finish setup in async context.""" + self.cache_update_lock = asyncio.Lock() self.async_poll = Debouncer( self.hass, _LOGGER, @@ -44,31 +43,37 @@ class SonosHouseholdCoordinator: function=self._async_poll, ).async_call + @property + def class_type(self) -> str: + """Return the class type of this instance.""" + return type(self).__name__ + async def _async_poll(self) -> None: """Poll any known speaker.""" discovered = self.hass.data[DATA_SONOS].discovered for uid, speaker in discovered.items(): - _LOGGER.debug("Updating %s using %s", type(self).__name__, speaker.soco) - success = await self.async_update_entities(speaker.soco) - - if success: + _LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco) + try: + await self.async_update_entities(speaker.soco) + except (OSError, SoCoException) as err: + _LOGGER.error( + "Could not refresh %s using %s: %s", + self.class_type, + speaker.soco, + err, + ) + else: # Prefer this SoCo instance next update discovered.move_to_end(uid, last=False) break - @callback - def async_handle_event(self, event_id: str, soco: SoCo) -> None: - """Create a task to update from an event callback.""" - if event_id in self._processed_events: - return - self._processed_events.append(event_id) - self.hass.async_create_task(self.async_update_entities(soco)) - - async def async_update_entities(self, soco: SoCo) -> bool: + async def async_update_entities( + self, soco: SoCo, update_id: int | None = None + ) -> None: """Update the cache and update entities.""" raise NotImplementedError() - def update_cache(self, soco: SoCo) -> Any: - """Update the cache of the household-level feature.""" + def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: + """Update the cache of the household-level feature and return if cache has changed.""" raise NotImplementedError() diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index d9c2a2cc6c9..249a6d4cc00 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": ["soco==0.23.3"], + "requirements": ["soco==0.24.0"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 30d107bdd8d..851711c2e12 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import contextlib import datetime from functools import partial import logging -from typing import Any, Callable +from typing import Any import urllib.parse import async_timeout @@ -59,7 +59,7 @@ from .const import ( SUBSCRIPTION_TIMEOUT, ) from .favorites import SonosFavorites -from .helpers import soco_error, uid_to_short_hostname +from .helpers import soco_error EVENT_CHARGING = { "CHARGING": True, @@ -177,6 +177,7 @@ class SonosSpeaker: # Device information self.mac_address = speaker_info["mac_address"] self.model_name = speaker_info["model_name"] + self.uid = speaker_info["uid"] self.version = speaker_info["display_version"] self.zone_name = speaker_info["zone_name"] @@ -450,7 +451,9 @@ class SonosSpeaker: """Add the soco instance associated with the event to the callback.""" if not (event_id := event.variables.get("favorites_update_id")): return - self.favorites.async_handle_event(event_id, self.soco) + if not (container_ids := event.variables.get("container_update_i_ds")): + return + self.favorites.async_handle_event(event_id, container_ids, self.soco) @callback def async_dispatch_media_update(self, event: SonosEvent) -> None: @@ -485,6 +488,15 @@ class SonosSpeaker: # # Speaker availability methods # + @callback + def _async_reset_seen_timer(self): + """Reset the _seen_timer scheduler.""" + if self._seen_timer: + self._seen_timer() + self._seen_timer = self.hass.helpers.event.async_call_later( + SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen + ) + async def async_seen(self, soco: SoCo | None = None) -> None: """Record that this speaker was seen right now.""" if soco is not None: @@ -492,12 +504,7 @@ class SonosSpeaker: was_available = self.available - if self._seen_timer: - self._seen_timer() - - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) + self._async_reset_seen_timer() if was_available: self.async_write_entity_states() @@ -512,8 +519,6 @@ class SonosSpeaker: if self._is_ready and not self.subscriptions_failed: done = await self.async_subscribe() if not done: - assert self._seen_timer is not None - self._seen_timer() await self.async_unseen() self.async_write_entity_states() @@ -522,21 +527,14 @@ class SonosSpeaker: self, callback_timestamp: datetime.datetime | None = None ) -> None: """Make this player unavailable when it was not seen recently.""" - if self._seen_timer: - self._seen_timer() - self._seen_timer = None - - if callback_timestamp: + data = self.hass.data[DATA_SONOS] + if callback_timestamp and (zcname := data.mdns_names.get(self.soco.uid)): # Called by a _seen_timer timeout, check mDNS one more time # This should not be checked in an "active" unseen scenario - hostname = uid_to_short_hostname(self.soco.uid) - zcname = f"{hostname}.{MDNS_SERVICE}" aiozeroconf = await zeroconf.async_get_async_instance(self.hass) if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): # We can still see the speaker via zeroconf check again later. - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) + self._async_reset_seen_timer() return _LOGGER.debug( @@ -546,6 +544,10 @@ class SonosSpeaker: self._share_link_plugin = None + if self._seen_timer: + self._seen_timer() + self._seen_timer = None + if self._poll_timer: self._poll_timer() self._poll_timer = None @@ -565,11 +567,7 @@ class SonosSpeaker: await self.async_unsubscribe() self.soco = soco await self.async_subscribe() - if self._seen_timer: - self._seen_timer() - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) + self._async_reset_seen_timer() self.async_write_entity_states() # @@ -907,10 +905,7 @@ class SonosSpeaker: 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] - ): + if speaker.snapshot_group not in (speaker.sonos_group, [speaker]): speaker.join(speaker.snapshot_group) groups.append(speaker.snapshot_group.copy()) @@ -1058,25 +1053,32 @@ class SonosSpeaker: def update_media_radio(self, variables: dict | None) -> None: """Update state when streaming radio.""" self.media.clear_position() + radio_title = None - try: - album_art_uri = variables["current_track_meta_data"].album_art_uri - self.media.image_url = self.media.library.build_album_art_full_uri( - album_art_uri - ) - except (TypeError, KeyError, AttributeError): - pass + if current_track_metadata := variables.get("current_track_meta_data"): + if album_art_uri := getattr(current_track_metadata, "album_art_uri", None): + self.media.image_url = self.media.library.build_album_art_full_uri( + album_art_uri + ) + if not self.media.artist: + self.media.artist = getattr(current_track_metadata, "creator", None) - if not self.media.artist: - try: - self.media.artist = variables["current_track_meta_data"].creator - except (TypeError, KeyError, AttributeError): - pass + # A missing artist implies metadata is incomplete, try a different method + if not self.media.artist: + radio_show = None + stream_content = None + if current_track_metadata.radio_show: + radio_show = current_track_metadata.radio_show.split(",")[0] + if not current_track_metadata.stream_content.startswith( + ("ZPSTR_", "TYPE=") + ): + stream_content = current_track_metadata.stream_content + radio_title = " • ".join(filter(None, [radio_show, stream_content])) - # Radios without tagging can have part of the radio URI as title. - # In this case we try to use the radio name instead. - try: - uri_meta_data = variables["enqueued_transport_uri_meta_data"] + if radio_title: + # Prefer the radio title created above + self.media.title = radio_title + elif uri_meta_data := variables.get("enqueued_transport_uri_meta_data"): if isinstance(uri_meta_data, DidlAudioBroadcast) and ( self.soco.music_source_from_uri(self.media.title) == MUSIC_SRC_RADIO or ( @@ -1088,18 +1090,23 @@ class SonosSpeaker: ) ) ): + # Fall back to the radio channel name as a last resort self.media.title = uri_meta_data.title - except (TypeError, KeyError, AttributeError): - pass media_info = self.soco.get_current_media_info() - self.media.channel = media_info["channel"] # Check if currently playing radio station is in favorites - for fav in self.favorites: - if fav.reference.get_uri() == media_info["uri"]: - self.media.source_name = fav.title + fav = next( + ( + fav + for fav in self.favorites + if fav.reference.get_uri() == media_info["uri"] + ), + None, + ) + if fav: + self.media.source_name = fav.title def update_media_music(self, track_info: dict) -> None: """Update state when playing music tracks.""" diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 482780453af..cee60cbbafa 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -37,8 +37,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: entities = [] + created_alarms = ( + hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids + ) for alarm_id in alarm_ids: + if alarm_id in created_alarms: + continue _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) + created_alarms.add(alarm_id) entities.append(SonosAlarmEntity(alarm_id, speaker)) async_add_entities(entities) diff --git a/homeassistant/components/sonos/translations/fr.json b/homeassistant/components/sonos/translations/fr.json index 50a6086e2e8..828a8e45791 100644 --- a/homeassistant/components/sonos/translations/fr.json +++ b/homeassistant/components/sonos/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique Sonos trouv\u00e9 sur le r\u00e9seau.", + "no_devices_found": "Aucun appareil 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." + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "step": { "confirm": { diff --git a/homeassistant/components/sonos/translations/he.json b/homeassistant/components/sonos/translations/he.json index 878c14a5119..64824b942ec 100644 --- a/homeassistant/components/sonos/translations/he.json +++ b/homeassistant/components/sonos/translations/he.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "not_sonos_device": "\u05d4\u05ea\u05e7\u05df \u05e9\u05d4\u05ea\u05d2\u05dc\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05d4\u05ea\u05e7\u05df Sonos", "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/sonos/translations/hu.json b/homeassistant/components/sonos/translations/hu.json index a928f97b3d6..a521c1e9d75 100644 --- a/homeassistant/components/sonos/translations/hu.json +++ b/homeassistant/components/sonos/translations/hu.json @@ -7,7 +7,7 @@ }, "step": { "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Sonos-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a Sonos-t?" } } } diff --git a/homeassistant/components/sonos/translations/id.json b/homeassistant/components/sonos/translations/id.json index 145e2775e4a..d64dccf3af3 100644 --- a/homeassistant/components/sonos/translations/id.json +++ b/homeassistant/components/sonos/translations/id.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "not_sonos_device": "Perangkat yang ditemukan bukan perangkat Sonos", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "step": { diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 62f7b2dbd73..fd49a2f6e3f 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -5,18 +5,11 @@ from datetime import timedelta import logging import speedtest -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_SCAN_INTERVAL, - EVENT_HOMEASSISTANT_STARTED, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED 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 ( @@ -26,59 +19,11 @@ from .const import ( DEFAULT_SERVER, DOMAIN, PLATFORMS, - SENSOR_TYPES, SPEED_TEST_SERVICE, ) _LOGGER = logging.getLogger(__name__) -SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] - -CONFIG_SCHEMA = vol.Schema( - vol.All( - # Deprecated in Home Assistant 2021.6 - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_SERVER_ID): cv.positive_int, - vol.Optional( - CONF_SCAN_INTERVAL, - default=timedelta(minutes=DEFAULT_SCAN_INTERVAL), - ): cv.positive_time_period, - vol.Optional(CONF_MANUAL, default=False): cv.boolean, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSOR_KEYS) - ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_KEYS))]), - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -def server_id_valid(server_id: str) -> bool: - """Check if server_id is valid.""" - try: - api = speedtest.Speedtest() - api.get_servers([int(server_id)]) - except (speedtest.ConfigRetrievalError, speedtest.NoMatchedServers): - return False - - return True - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import integration from config.""" - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Speedtest.net component.""" @@ -138,6 +83,11 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): update_method=self.async_update, ) + def initialize(self) -> None: + """Initialize speedtest api.""" + self.api = speedtest.Speedtest() + self.update_servers() + def update_servers(self): """Update list of test servers.""" test_servers = self.api.get_servers() @@ -145,18 +95,17 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): 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 + 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.""" @@ -184,24 +133,10 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): except speedtest.SpeedtestException as err: raise UpdateFailed(err) from err - async def async_set_options(self): - """Set options for entry.""" - if not self.config_entry.options: - data = {**self.config_entry.data} - options = { - CONF_SCAN_INTERVAL: data.pop(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - CONF_MANUAL: data.pop(CONF_MANUAL, False), - CONF_SERVER_ID: str(data.pop(CONF_SERVER_ID, "")), - } - self.hass.config_entries.async_update_entry( - self.config_entry, data=data, options=options - ) - async def async_setup(self) -> None: """Set up SpeedTest.""" try: - self.api = await self.hass.async_add_executor_job(speedtest.Speedtest) - await self.hass.async_add_executor_job(self.update_servers) + await self.hass.async_add_executor_job(self.initialize) except speedtest.SpeedtestException as err: raise ConfigEntryNotReady from err @@ -209,8 +144,6 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): """Request update.""" await self.async_request_refresh() - await self.async_set_options() - self.hass.services.async_register(DOMAIN, SPEED_TEST_SERVICE, request_update) self.config_entry.async_on_unload( diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index e5462aa9379..d82ac6bf728 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -6,11 +6,10 @@ 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.const import CONF_SCAN_INTERVAL from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from . import server_id_valid from .const import ( CONF_MANUAL, CONF_SERVER_ID, @@ -47,23 +46,6 @@ class SpeedTestFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data=user_input) - async def async_step_import(self, import_config): - """Import from config.""" - if ( - CONF_SERVER_ID in import_config - and not await self.hass.async_add_executor_job( - server_id_valid, import_config[CONF_SERVER_ID] - ) - ): - return self.async_abort(reason="wrong_server_id") - - import_config[CONF_SCAN_INTERVAL] = int( - import_config[CONF_SCAN_INTERVAL].total_seconds() / 60 - ) - import_config.pop(CONF_MONITORED_CONDITIONS) - - return await self.async_step_user(user_input=import_config) - class SpeedTestOptionsFlowHandler(config_entries.OptionsFlow): """Handle SpeedTest options.""" @@ -91,21 +73,10 @@ class SpeedTestOptionsFlowHandler(config_entries.OptionsFlow): self._servers = self.hass.data[DOMAIN].servers - server = [] - if self.config_entry.options.get( - CONF_SERVER_ID - ) and not self.config_entry.options.get(CONF_SERVER_NAME): - server = [ - key - for (key, value) in self._servers.items() - if value.get("id") == self.config_entry.options[CONF_SERVER_ID] - ] - server_name = server[0] if server else DEFAULT_SERVER - options = { vol.Optional( CONF_SERVER_NAME, - default=self.config_entry.options.get(CONF_SERVER_NAME, server_name), + default=self.config_entry.options.get(CONF_SERVER_NAME, DEFAULT_SERVER), ): vol.In(self._servers.keys()), vol.Optional( CONF_SCAN_INTERVAL, diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index c9962362406..57beaf99eb9 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,7 +1,8 @@ -"""Consts used by Speedtest.net.""" +"""Constants used by Speedtest.net.""" from __future__ import annotations -from typing import Final +from dataclasses import dataclass +from typing import Callable, Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -13,24 +14,34 @@ DOMAIN: Final = "speedtestdotnet" SPEED_TEST_SERVICE: Final = "speedtest" -SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( - SensorEntityDescription( + +@dataclass +class SpeedtestSensorEntityDescription(SensorEntityDescription): + """Class describing Speedtest sensor entities.""" + + value: Callable = round + + +SENSOR_TYPES: Final[tuple[SpeedtestSensorEntityDescription, ...]] = ( + SpeedtestSensorEntityDescription( key="ping", name="Ping", native_unit_of_measurement=TIME_MILLISECONDS, state_class=STATE_CLASS_MEASUREMENT, ), - SensorEntityDescription( + SpeedtestSensorEntityDescription( key="download", name="Download", native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value / 10 ** 6, 2), ), - SensorEntityDescription( + SpeedtestSensorEntityDescription( key="upload", name="Upload", native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value / 10 ** 6, 2), ), ) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 2dc12c956de..fa9cd137ba1 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -1,15 +1,16 @@ """Support for Speedtest.net internet speed testing sensor.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import SensorEntity from homeassistant.components.speedtestdotnet import SpeedTestDataCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -23,6 +24,7 @@ from .const import ( DOMAIN, ICON, SENSOR_TYPES, + SpeedtestSensorEntityDescription, ) @@ -43,21 +45,34 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Implementation of a speedtest.net sensor.""" coordinator: SpeedTestDataCoordinator - + entity_description: SpeedtestSensorEntityDescription _attr_icon = ICON def __init__( self, coordinator: SpeedTestDataCoordinator, - description: SensorEntityDescription, + description: SpeedtestSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{DEFAULT_NAME} {description.name}" self._attr_unique_id = description.key + self._state: StateType = None self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_device_info = { + "identifiers": {(DOMAIN, self.coordinator.config_entry.entry_id)}, + "name": DEFAULT_NAME, + "entry_type": "service", + } + + @property + def native_value(self) -> StateType: + """Return native value for entity.""" + if self.coordinator.data: + state = self.coordinator.data[self.entity_description.key] + self._state = cast(StateType, self.entity_description.value(state)) + return self._state @property def extra_state_attributes(self) -> dict[str, Any]: @@ -73,10 +88,10 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): if self.entity_description.key == "download": self._attrs[ATTR_BYTES_RECEIVED] = self.coordinator.data[ - "bytes_received" + ATTR_BYTES_RECEIVED ] elif self.entity_description.key == "upload": - self._attrs[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] + self._attrs[ATTR_BYTES_SENT] = self.coordinator.data[ATTR_BYTES_SENT] return self._attrs @@ -85,27 +100,4 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): await super().async_added_to_hass() state = await self.async_get_last_state() if state: - self._attr_native_value = state.state - - @callback - def update() -> None: - """Update state.""" - self._update_state() - self.async_write_ha_state() - - self.async_on_remove(self.coordinator.async_add_listener(update)) - self._update_state() - - def _update_state(self): - """Update sensors state.""" - if self.coordinator.data: - if self.entity_description.key == "ping": - self._attr_native_value = self.coordinator.data["ping"] - elif self.entity_description.key == "download": - self._attr_native_value = round( - self.coordinator.data["download"] / 10 ** 6, 2 - ) - elif self.entity_description.key == "upload": - self._attr_native_value = round( - self.coordinator.data["upload"] / 10 ** 6, 2 - ) + self._state = state.state diff --git a/homeassistant/components/speedtestdotnet/translations/fr.json b/homeassistant/components/speedtestdotnet/translations/fr.json index 9407a674550..41a1ae7dc2d 100644 --- a/homeassistant/components/speedtestdotnet/translations/fr.json +++ b/homeassistant/components/speedtestdotnet/translations/fr.json @@ -6,7 +6,7 @@ }, "step": { "user": { - "description": "Voulez-vous vraiment configurer SpeedTest ?" + "description": "Voulez-vous commencer la configuration ?" } } }, diff --git a/homeassistant/components/speedtestdotnet/translations/hu.json b/homeassistant/components/speedtestdotnet/translations/hu.json index cd08c3bd2d6..9e602652e5b 100644 --- a/homeassistant/components/speedtestdotnet/translations/hu.json +++ b/homeassistant/components/speedtestdotnet/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } }, @@ -16,7 +16,7 @@ "data": { "manual": "Automatikus friss\u00edt\u00e9s letilt\u00e1sa", "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g (perc)", - "server_name": "V\u00e1laszd ki a teszt szervert" + "server_name": "V\u00e1lassza ki a teszt szervert" } } } diff --git a/homeassistant/components/speedtestdotnet/translations/nl.json b/homeassistant/components/speedtestdotnet/translations/nl.json index 5de8460fd77..3a112d48e9d 100644 --- a/homeassistant/components/speedtestdotnet/translations/nl.json +++ b/homeassistant/components/speedtestdotnet/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } }, diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index fedec630c35..780febf6791 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -207,6 +207,7 @@ def spotify_exception_handler(func): """ def wrapper(self, *args, **kwargs): + # pylint: disable=protected-access try: result = func(self, *args, **kwargs) self._attr_available = True diff --git a/homeassistant/components/spotify/translations/fr.json b/homeassistant/components/spotify/translations/fr.json index d6b5838feb5..4422ddef176 100644 --- a/homeassistant/components/spotify/translations/fr.json +++ b/homeassistant/components/spotify/translations/fr.json @@ -11,11 +11,11 @@ }, "step": { "pick_implementation": { - "title": "Choisissez la m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "reauth_confirm": { "description": "L'int\u00e9gration de Spotify doit se r\u00e9-authentifier avec Spotify pour le compte: {account}", - "title": "R\u00e9-authentifier avec Spotify" + "title": "R\u00e9-authentifier l'int\u00e9gration" } } }, diff --git a/homeassistant/components/spotify/translations/hu.json b/homeassistant/components/spotify/translations/hu.json index 8ffeadaf842..136e6185b46 100644 --- a/homeassistant/components/spotify/translations/hu.json +++ b/homeassistant/components/spotify/translations/hu.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", + "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.", "reauth_account_mismatch": "A Spotify-fi\u00f3kkal hiteles\u00edtett fi\u00f3k nem egyezik meg az \u00faj hiteles\u00edt\u00e9shez sz\u00fcks\u00e9ges fi\u00f3kkal." diff --git a/homeassistant/components/squeezebox/translations/fr.json b/homeassistant/components/squeezebox/translations/fr.json index f79d25bc20a..c416a18b49d 100644 --- a/homeassistant/components/squeezebox/translations/fr.json +++ b/homeassistant/components/squeezebox/translations/fr.json @@ -17,7 +17,7 @@ "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "username": "Username" + "username": "Nom d'utilisateur" }, "title": "Modifier les informations de connexion" }, diff --git a/homeassistant/components/squeezebox/translations/hu.json b/homeassistant/components/squeezebox/translations/hu.json index a047dbca45f..5c2a3d37e85 100644 --- a/homeassistant/components/squeezebox/translations/hu.json +++ b/homeassistant/components/squeezebox/translations/hu.json @@ -14,7 +14,7 @@ "step": { "edit": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" @@ -23,7 +23,7 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" } } } diff --git a/homeassistant/components/squeezebox/translations/id.json b/homeassistant/components/squeezebox/translations/id.json index 764c356ba84..02d82e872d8 100644 --- a/homeassistant/components/squeezebox/translations/id.json +++ b/homeassistant/components/squeezebox/translations/id.json @@ -10,7 +10,7 @@ "no_server_found": "Tidak dapat menemukan server secara otomatis.", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Logitech Squeezebox: {host}", + "flow_title": "{host}", "step": { "edit": { "data": { diff --git a/homeassistant/components/srp_energy/translations/fr.json b/homeassistant/components/srp_energy/translations/fr.json index b9b33cfa930..4ce2a6dbbea 100644 --- a/homeassistant/components/srp_energy/translations/fr.json +++ b/homeassistant/components/srp_energy/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "single_instance_allowed": "D\u00e9ja configur\u00e9. Seulement une seule configuration est possible " + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { - "cannot_connect": "\u00c9chec de la connexion ", + "cannot_connect": "\u00c9chec de connexion", "invalid_account": "L'ID de compte doit \u00eatre un num\u00e9ro \u00e0 9 chiffres", - "invalid_auth": "Authentification invalide ", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { @@ -15,7 +15,7 @@ "id": "Identifiant de compte", "is_tou": "Est le plan de temps d'utilisation", "password": "Mot de passe", - "username": "Nom d'utilisateur " + "username": "Nom d'utilisateur" } } } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 63ad6acb181..b06f1b34493 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import Awaitable from datetime import timedelta +from enum import Enum from ipaddress import IPv4Address, IPv6Address import logging -from typing import Any, Callable +from typing import Any, Callable, Mapping -from async_upnp_client.search import SSDPListener +from async_upnp_client.aiohttp import AiohttpSessionRequester +from async_upnp_client.const import DeviceOrServiceType, SsdpHeaders, SsdpSource +from async_upnp_client.description_cache import DescriptionCache from async_upnp_client.ssdp import SSDP_PORT +from async_upnp_client.ssdp_listener import SsdpDevice, SsdpListener from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries @@ -19,12 +23,12 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, MATCH_ALL, ) -from homeassistant.core import CoreState, HomeAssistant, callback as core_callback +from homeassistant.core import HomeAssistant, callback as core_callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass -from .descriptions import DescriptionManager from .flow import FlowDispatcher, SSDPFlow DOMAIN = "ssdp" @@ -35,9 +39,13 @@ IPV4_BROADCAST = IPv4Address("255.255.255.255") # Attributes for accessing info from SSDP response ATTR_SSDP_LOCATION = "ssdp_location" ATTR_SSDP_ST = "ssdp_st" +ATTR_SSDP_NT = "ssdp_nt" +ATTR_SSDP_UDN = "ssdp_udn" ATTR_SSDP_USN = "ssdp_usn" ATTR_SSDP_EXT = "ssdp_ext" ATTR_SSDP_SERVER = "ssdp_server" +ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" +ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description ATTR_UPNP_DEVICE_TYPE = "deviceType" ATTR_UPNP_FRIENDLY_NAME = "friendlyName" @@ -52,6 +60,7 @@ ATTR_UPNP_UDN = "UDN" ATTR_UPNP_UPC = "UPC" ATTR_UPNP_PRESENTATION_URL = "presentationURL" +PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE, "nt"] DISCOVERY_MAPPING = { "usn": ATTR_SSDP_USN, @@ -59,16 +68,29 @@ DISCOVERY_MAPPING = { "server": ATTR_SSDP_SERVER, "st": ATTR_SSDP_ST, "location": ATTR_SSDP_LOCATION, + "_udn": ATTR_SSDP_UDN, + "nt": ATTR_SSDP_NT, } +SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") +SsdpCallback = Callable[[Mapping[str, Any], SsdpChange], Awaitable] + + +SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { + SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, + SsdpSource.SEARCH_CHANGED: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, + SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, +} _LOGGER = logging.getLogger(__name__) @bind_hass -def async_register_callback( +async def async_register_callback( hass: HomeAssistant, - callback: Callable[[dict], None], + callback: SsdpCallback, match_dict: None | dict[str, str] = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -76,60 +98,64 @@ def async_register_callback( Returns a callback that can be used to cancel the registration. """ scanner: Scanner = hass.data[DOMAIN] - return scanner.async_register_callback(callback, match_dict) + return await scanner.async_register_callback(callback, match_dict) @bind_hass -def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name hass: HomeAssistant, udn: str, st: str ) -> dict[str, str] | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN] - return scanner.async_get_discovery_info_by_udn_st(udn, st) + return await scanner.async_get_discovery_info_by_udn_st(udn, st) @bind_hass -def async_get_discovery_info_by_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_st( # pylint: disable=invalid-name hass: HomeAssistant, st: str ) -> list[dict[str, str]]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN] - return scanner.async_get_discovery_info_by_st(st) + return await scanner.async_get_discovery_info_by_st(st) @bind_hass -def async_get_discovery_info_by_udn( +async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str ) -> list[dict[str, str]]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN] - return scanner.async_get_discovery_info_by_udn(udn) + return await scanner.async_get_discovery_info_by_udn(udn) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SSDP integration.""" - scanner = hass.data[DOMAIN] = Scanner(hass, await async_get_ssdp(hass)) + integration_matchers = IntegrationMatchers() + integration_matchers.async_setup(await async_get_ssdp(hass)) + + scanner = hass.data[DOMAIN] = Scanner(hass, integration_matchers) asyncio.create_task(scanner.async_start()) return True -@core_callback -def _async_process_callbacks( - callbacks: list[Callable[[dict], None]], discovery_info: dict[str, str] +async def _async_process_callbacks( + callbacks: list[SsdpCallback], + discovery_info: dict[str, str], + ssdp_change: SsdpChange, ) -> None: for callback in callbacks: try: - callback(discovery_info) + await callback(discovery_info, ssdp_change) except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to callback info: %s", discovery_info) @core_callback def _async_headers_match( - headers: Mapping[str, str], match_dict: dict[str, str] + headers: Mapping[str, Any], match_dict: dict[str, str] ) -> bool: for header, val in match_dict.items(): if val == MATCH_ALL: @@ -140,26 +166,87 @@ def _async_headers_match( return True +class IntegrationMatchers: + """Optimized integration matching.""" + + def __init__(self) -> None: + """Init optimized integration matching.""" + self._match_by_key: dict[ + str, dict[str, list[tuple[str, dict[str, str]]]] + ] | None = None + + @core_callback + def async_setup( + self, integration_matchers: dict[str, list[dict[str, str]]] + ) -> None: + """Build matchers by key. + + Here we convert the primary match keys into their own + dicts so we can do lookups of the primary match + key to find the match dict. + """ + self._match_by_key = {} + for key in PRIMARY_MATCH_KEYS: + matchers_by_key = self._match_by_key[key] = {} + for domain, matchers in integration_matchers.items(): + for matcher in matchers: + if match_value := matcher.get(key): + matchers_by_key.setdefault(match_value, []).append( + (domain, matcher) + ) + + @core_callback + def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: + """Find domains matching the passed CaseInsensitiveDict.""" + assert self._match_by_key is not None + domains = set() + for key, matchers_by_key in self._match_by_key.items(): + if not (match_value := info_with_desc.get(key)): + continue + for domain, matcher in matchers_by_key.get(match_value, []): + if domain in domains: + continue + if all(info_with_desc.get(k) == v for (k, v) in matcher.items()): + domains.add(domain) + return domains + + class Scanner: - """Class to manage SSDP scanning.""" + """Class to manage SSDP searching and SSDP advertisements.""" def __init__( - self, hass: HomeAssistant, integration_matchers: dict[str, list[dict[str, str]]] + self, hass: HomeAssistant, integration_matchers: IntegrationMatchers ) -> None: """Initialize class.""" self.hass = hass - self.seen: set[tuple[str, str | None]] = set() - self.cache: dict[tuple[str, str], Mapping[str, str]] = {} - self._integration_matchers = integration_matchers self._cancel_scan: Callable[[], None] | None = None - self._ssdp_listeners: list[SSDPListener] = [] - self._callbacks: list[tuple[Callable[[dict], None], dict[str, str]]] = [] - self.flow_dispatcher: FlowDispatcher | None = None - self.description_manager: DescriptionManager | None = None + self._ssdp_listeners: list[SsdpListener] = [] + self._callbacks: list[tuple[SsdpCallback, dict[str, str]]] = [] + self._flow_dispatcher: FlowDispatcher | None = None + self._description_cache: DescriptionCache | None = None + self.integration_matchers = integration_matchers - @core_callback - def async_register_callback( - self, callback: Callable[[dict], None], match_dict: None | dict[str, str] = None + @property + def _ssdp_devices(self) -> list[SsdpDevice]: + """Get all seen devices.""" + return [ + ssdp_device + for ssdp_listener in self._ssdp_listeners + for ssdp_device in ssdp_listener.devices.values() + ] + + @property + def _all_headers_from_ssdp_devices( + self, + ) -> dict[tuple[str, str], Mapping[str, Any]]: + return { + (ssdp_device.udn, dst): headers + for ssdp_device in self._ssdp_devices + for dst, headers in ssdp_device.all_combined_headers.items() + } + + async def async_register_callback( + self, callback: SsdpCallback, match_dict: None | dict[str, str] = None ) -> Callable[[], None]: """Register a callback.""" if match_dict is None: @@ -167,12 +254,13 @@ class Scanner: # Make sure any entries that happened # before the callback was registered are fired - if self.hass.state != CoreState.running: - for headers in self.cache.values(): - if _async_headers_match(headers, match_dict): - _async_process_callbacks( - [callback], self._async_headers_to_discovery_info(headers) - ) + for headers in self._all_headers_from_ssdp_devices.values(): + if _async_headers_match(headers, match_dict): + await _async_process_callbacks( + [callback], + await self._async_headers_to_discovery_info(headers), + SsdpChange.ALIVE, + ) callback_entry = (callback, match_dict) self._callbacks.append(callback_entry) @@ -183,14 +271,19 @@ class Scanner: return _async_remove_callback - @core_callback - def async_stop(self, *_: Any) -> None: + async def async_stop(self, *_: Any) -> None: """Stop the scanner.""" assert self._cancel_scan is not None self._cancel_scan() - for listener in self._ssdp_listeners: - listener.async_stop() - self._ssdp_listeners = [] + + await self._async_stop_ssdp_listeners() + + async def _async_stop_ssdp_listeners(self) -> None: + """Stop the SSDP listeners.""" + await asyncio.gather( + *(listener.async_stop() for listener in self._ssdp_listeners), + return_exceptions=True, + ) async def _async_build_source_set(self) -> set[IPv4Address | IPv6Address]: """Build the list of ssdp sources.""" @@ -208,34 +301,56 @@ class Scanner: } async def async_scan(self, *_: Any) -> None: - """Scan for new entries using ssdp default and broadcast target.""" + """Scan for new entries using ssdp listeners.""" + await self.async_scan_multicast() + await self.async_scan_broadcast() + + async def async_scan_multicast(self, *_: Any) -> None: + """Scan for new entries using multicase target.""" + for ssdp_listener in self._ssdp_listeners: + await ssdp_listener.async_search() + + async def async_scan_broadcast(self, *_: Any) -> None: + """Scan for new entries using broadcast target.""" + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 for listener in self._ssdp_listeners: - listener.async_search() try: IPv4Address(listener.source_ip) except ValueError: continue - # Some sonos devices only seem to respond if we send to the broadcast - # address. This matches pysonos' behavior - # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 - listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) + await listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) async def async_start(self) -> None: - """Start the scanner.""" - self.description_manager = DescriptionManager(self.hass) - self.flow_dispatcher = FlowDispatcher(self.hass) + """Start the scanners.""" + session = async_get_clientsession(self.hass) + requester = AiohttpSessionRequester(session, True, 10) + self._description_cache = DescriptionCache(requester) + self._flow_dispatcher = FlowDispatcher(self.hass) + + await self._async_start_ssdp_listeners() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, self._flow_dispatcher.async_start + ) + self._cancel_scan = async_track_time_interval( + self.hass, self.async_scan, SCAN_INTERVAL + ) + + # Trigger the initial-scan. + await self.async_scan() + + async def _async_start_ssdp_listeners(self) -> None: + """Start the SSDP Listeners.""" for source_ip in await self._async_build_source_set(): self._ssdp_listeners.append( - SSDPListener( - async_connect_callback=self.async_scan, - async_callback=self._async_process_entry, + SsdpListener( + async_callback=self._ssdp_listener_callback, source_ip=source_ip, ) ) - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start - ) results = await asyncio.gather( *(listener.async_start() for listener in self._ssdp_listeners), return_exceptions=True, @@ -251,135 +366,123 @@ class Scanner: 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 - ) @core_callback def _async_get_matching_callbacks( - self, headers: Mapping[str, str] - ) -> list[Callable[[dict], None]]: + self, + combined_headers: SsdpHeaders, + ) -> list[SsdpCallback]: """Return a list of callbacks that match.""" return [ callback for callback, match_dict in self._callbacks - if _async_headers_match(headers, match_dict) + if _async_headers_match(combined_headers, match_dict) ] - @core_callback - def _async_matching_domains(self, info_with_req: CaseInsensitiveDict) -> set[str]: - domains = set() - for domain, matchers in self._integration_matchers.items(): - for matcher in matchers: - if all(info_with_req.get(k) == v for (k, v) in matcher.items()): - domains.add(domain) - return domains + async def _ssdp_listener_callback( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + ) -> None: + """Handle a device/service change.""" + _LOGGER.debug( + "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source + ) - def _async_seen(self, header_st: str | None, header_location: str | None) -> bool: - """Check if we have seen a specific st and optional location.""" - if header_st is None: - return True - return (header_st, header_location) in self.seen + location = ssdp_device.location + info_desc = await self._async_get_description_dict(location) or {} + combined_headers = ssdp_device.combined_headers(dst) + info_with_desc = CaseInsensitiveDict(combined_headers, **info_desc) - def _async_see(self, header_st: str | None, header_location: str | None) -> None: - """Mark a specific st and optional location as seen.""" - if header_st is not None: - self.seen.add((header_st, header_location)) + callbacks = self._async_get_matching_callbacks(combined_headers) + matching_domains: set[str] = set() - def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: - """If we see a device in a new location, unsee the original location.""" - if header_st is not None: - self.seen.discard((header_st, header_location)) + # If there are no changes from a search, do not trigger a config flow + if source != SsdpSource.SEARCH_ALIVE: + matching_domains = self.integration_matchers.async_matching_domains( + info_with_desc + ) - async def _async_process_entry(self, headers: Mapping[str, str]) -> None: - """Process SSDP entries.""" - _LOGGER.debug("_async_process_entry: %s", headers) - h_st = headers.get("st") - h_location = headers.get("location") - - if h_st and (udn := _udn_from_usn(headers.get("usn"))): - cache_key = (udn, h_st) - if old_headers := self.cache.get(cache_key): - old_h_location = old_headers.get("location") - if h_location != old_h_location: - self._async_unsee(old_headers.get("st"), old_h_location) - self.cache[cache_key] = headers - - callbacks = self._async_get_matching_callbacks(headers) - if self._async_seen(h_st, h_location) and not callbacks: + if not callbacks and not matching_domains: return - assert self.description_manager is not None - info_req = await self.description_manager.fetch_description(h_location) or {} - info_with_req = CaseInsensitiveDict(**headers, **info_req) - discovery_info = discovery_info_from_headers_and_request(info_with_req) + discovery_info = discovery_info_from_headers_and_description(info_with_desc) + ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] + await _async_process_callbacks(callbacks, discovery_info, ssdp_change) - _async_process_callbacks(callbacks, discovery_info) - - if self._async_seen(h_st, h_location): + # Config flows should only be created for alive/update messages from alive devices + if ssdp_change == SsdpChange.BYEBYE: return - self._async_see(h_st, h_location) - for domain in self._async_matching_domains(info_with_req): - _LOGGER.debug("Discovered %s at %s", domain, h_location) + for domain in matching_domains: + _LOGGER.debug("Discovered %s at %s", domain, location) flow: SSDPFlow = { "domain": domain, "context": {"source": config_entries.SOURCE_SSDP}, "data": discovery_info, } - assert self.flow_dispatcher is not None - self.flow_dispatcher.create(flow) + assert self._flow_dispatcher is not None + self._flow_dispatcher.create(flow) - @core_callback - def _async_headers_to_discovery_info( - self, headers: Mapping[str, str] - ) -> dict[str, str]: + async def _async_get_description_dict( + self, location: str | None + ) -> Mapping[str, str]: + """Get description dict.""" + assert self._description_cache is not None + return await self._description_cache.async_get_description_dict(location) or {} + + async def _async_headers_to_discovery_info( + self, headers: Mapping[str, Any] + ) -> dict[str, Any]: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. """ - assert self.description_manager is not None + assert self._description_cache is not None location = headers["location"] - info_req = self.description_manager.async_cached_description(location) or {} - return discovery_info_from_headers_and_request( - CaseInsensitiveDict(**headers, **info_req) + info_desc = ( + await self._description_cache.async_get_description_dict(location) or {} + ) + return discovery_info_from_headers_and_description( + CaseInsensitiveDict(headers, **info_desc) ) - @core_callback - def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name + async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name self, udn: str, st: str - ) -> dict[str, str] | None: + ) -> dict[str, Any] | None: """Return discovery_info for a udn and st.""" - if headers := self.cache.get((udn, st)): - return self._async_headers_to_discovery_info(headers) + if headers := self._all_headers_from_ssdp_devices.get((udn, st)): + return await self._async_headers_to_discovery_info(headers) return None - @core_callback - def async_get_discovery_info_by_st( # pylint: disable=invalid-name + async def async_get_discovery_info_by_st( # pylint: disable=invalid-name self, st: str - ) -> list[dict[str, str]]: + ) -> list[dict[str, Any]]: """Return matching discovery_infos for a st.""" return [ - self._async_headers_to_discovery_info(headers) - for udn_st, headers in self.cache.items() + await self._async_headers_to_discovery_info(headers) + for udn_st, headers in self._all_headers_from_ssdp_devices.items() if udn_st[1] == st ] - @core_callback - def async_get_discovery_info_by_udn(self, udn: str) -> list[dict[str, str]]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[dict[str, Any]]: """Return matching discovery_infos for a udn.""" return [ - self._async_headers_to_discovery_info(headers) - for udn_st, headers in self.cache.items() + await self._async_headers_to_discovery_info(headers) + for udn_st, headers in self._all_headers_from_ssdp_devices.items() if udn_st[0] == udn ] -def discovery_info_from_headers_and_request( - info_with_req: CaseInsensitiveDict, -) -> dict[str, str]: +def discovery_info_from_headers_and_description( + info_with_desc: CaseInsensitiveDict, +) -> dict[str, Any]: """Convert headers and description to discovery_info.""" - info = {DISCOVERY_MAPPING.get(k.lower(), k): v for k, v in info_with_req.items()} + info = { + DISCOVERY_MAPPING.get(k.lower(), k): v + for k, v in info_with_desc.as_dict().items() + } if ATTR_UPNP_UDN not in info and ATTR_SSDP_USN in info: if udn := _udn_from_usn(info[ATTR_SSDP_USN]): diff --git a/homeassistant/components/ssdp/descriptions.py b/homeassistant/components/ssdp/descriptions.py deleted file mode 100644 index e754b10669a..00000000000 --- a/homeassistant/components/ssdp/descriptions.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The SSDP integration.""" -from __future__ import annotations - -import asyncio -import logging - -import aiohttp -from defusedxml import ElementTree - -from homeassistant.core import HomeAssistant, callback - -from .util import etree_to_dict - -_LOGGER = logging.getLogger(__name__) - - -class DescriptionManager: - """Class to cache and manage fetching descriptions.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Init the manager.""" - self.hass = hass - self._description_cache: dict[str, None | dict[str, str]] = {} - - async def fetch_description( - self, xml_location: str | None - ) -> None | dict[str, str]: - """Fetch the location or get it from the cache.""" - if xml_location is None: - return None - if xml_location not in self._description_cache: - try: - self._description_cache[xml_location] = await self._fetch_description( - xml_location - ) - except Exception: # pylint: disable=broad-except - # If it fails, cache the failure so we do not keep trying over and over - self._description_cache[xml_location] = None - _LOGGER.exception("Failed to fetch ssdp data from: %s", xml_location) - - return self._description_cache[xml_location] - - @callback - def async_cached_description(self, xml_location: str) -> None | dict[str, str]: - """Fetch the description from the cache.""" - return self._description_cache.get(xml_location) - - async def _fetch_description(self, xml_location: str) -> None | dict[str, str]: - """Fetch an XML description.""" - session = self.hass.helpers.aiohttp_client.async_get_clientsession() - try: - for _ in range(2): - resp = await session.get(xml_location, timeout=5) - # Samsung Smart TV sometimes returns an empty document the - # first time. Retry once. - if xml := await resp.text(errors="replace"): - break - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - _LOGGER.debug("Error fetching %s: %s", xml_location, err) - return None - - try: - tree = ElementTree.fromstring(xml) - except ElementTree.ParseError as err: - _LOGGER.debug("Error parsing %s: %s", xml_location, err) - return None - - root = etree_to_dict(tree).get("root") or {} - return root.get("device") or {} diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 746e90c7388..3e99a77e8bb 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,10 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": [ - "defusedxml==0.7.1", - "async-upnp-client==0.20.0" - ], + "requirements": ["async-upnp-client==0.22.5"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/ssdp/util.py b/homeassistant/components/ssdp/util.py deleted file mode 100644 index c28f8ce088d..00000000000 --- a/homeassistant/components/ssdp/util.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Util functions used by SSDP.""" -from __future__ import annotations - -from collections import defaultdict -from typing import Any - -from defusedxml import ElementTree - - -# Adapted from http://stackoverflow.com/a/10077069 -# to follow the XML to JSON spec -# https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html -def etree_to_dict(tree: ElementTree) -> dict[str, dict[str, Any] | None]: - """Convert an ETree object to a dict.""" - # strip namespace - tag_name = tree.tag[tree.tag.find("}") + 1 :] - - tree_dict: dict[str, dict[str, Any] | None] = { - tag_name: {} if tree.attrib else None - } - children = list(tree) - if children: - child_dict: dict[str, list] = defaultdict(list) - for child in map(etree_to_dict, children): - for k, val in child.items(): - child_dict[k].append(val) - tree_dict = { - tag_name: {k: v[0] if len(v) == 1 else v for k, v in child_dict.items()} - } - dict_meta = tree_dict[tag_name] - if tree.attrib: - assert dict_meta is not None - dict_meta.update(("@" + k, v) for k, v in tree.attrib.items()) - if tree.text: - text = tree.text.strip() - if children or tree.attrib: - if text: - assert dict_meta is not None - dict_meta["#text"] = text - else: - tree_dict[tag_name] = text - return tree_dict diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 8af9940370e..9033375ce90 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -1,8 +1,9 @@ """StarLine Account.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta -from typing import Any, Callable +from typing import Any from starline import StarlineApi, StarlineDevice diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index b48816e1a7c..727960e5f46 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -1,7 +1,7 @@ """StarLine base entity.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index c7ca853c20c..1d3a46d0273 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -25,24 +25,33 @@ import time from types import MappingProxyType from typing import cast +import voluptuous as vol + from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ENDPOINTS, + ATTR_SETTINGS, ATTR_STREAMS, + CONF_LL_HLS, + CONF_PART_DURATION, + CONF_SEGMENT_DURATION, DOMAIN, HLS_PROVIDER, MAX_SEGMENTS, OUTPUT_IDLE_TIMEOUT, RECORDER_PROVIDER, + SEGMENT_DURATION_ADJUSTER, STREAM_RESTART_INCREMENT, STREAM_RESTART_RESET_TIME, + TARGET_SEGMENT_DURATION_NON_LL_HLS, ) -from .core import PROVIDERS, IdleTimer, StreamOutput -from .hls import async_setup_hls +from .core import PROVIDERS, IdleTimer, StreamOutput, StreamSettings +from .hls import HlsStreamOutput, async_setup_hls _LOGGER = logging.getLogger(__name__) @@ -78,6 +87,24 @@ def create_stream( return stream +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LL_HLS, default=False): cv.boolean, + vol.Optional(CONF_SEGMENT_DURATION, default=6): vol.All( + cv.positive_float, vol.Range(min=2, max=10) + ), + vol.Optional(CONF_PART_DURATION, default=1): vol.All( + cv.positive_float, vol.Range(min=0.2, max=1.5) + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up stream.""" # Set log level to error for libav @@ -91,6 +118,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][ATTR_ENDPOINTS] = {} hass.data[DOMAIN][ATTR_STREAMS] = [] + if (conf := config.get(DOMAIN)) and conf[CONF_LL_HLS]: + assert isinstance(conf[CONF_SEGMENT_DURATION], float) + assert isinstance(conf[CONF_PART_DURATION], float) + hass.data[DOMAIN][ATTR_SETTINGS] = StreamSettings( + ll_hls=True, + min_segment_duration=conf[CONF_SEGMENT_DURATION] + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=conf[CONF_PART_DURATION], + hls_advance_part_limit=max(int(3 / conf[CONF_PART_DURATION]), 3), + hls_part_timeout=2 * conf[CONF_PART_DURATION], + ) + else: + hass.data[DOMAIN][ATTR_SETTINGS] = StreamSettings( + ll_hls=False, + min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS, + hls_advance_part_limit=3, + hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS, + ) # Setup HLS hls_endpoint = async_setup_hls(hass) @@ -206,11 +253,16 @@ class Stream: # pylint: disable=import-outside-toplevel from .worker import SegmentBuffer, stream_worker - segment_buffer = SegmentBuffer(self.outputs) + segment_buffer = SegmentBuffer(self.hass, self.outputs) wait_timeout = 0 while not self._thread_quit.wait(timeout=wait_timeout): start_time = time.time() - stream_worker(self.source, self.options, segment_buffer, self._thread_quit) + stream_worker( + self.source, + self.options, + segment_buffer, + self._thread_quit, + ) segment_buffer.discontinuity() if not self.keepalive or self._thread_quit.is_set(): if self._fast_restart_once: @@ -288,7 +340,7 @@ class Stream: _LOGGER.debug("Started a stream recording of %s seconds", duration) # Take advantage of lookback - hls = self.outputs().get(HLS_PROVIDER) + hls: HlsStreamOutput = cast(HlsStreamOutput, self.outputs().get(HLS_PROVIDER)) if lookback > 0 and hls: num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) # Wait for latest segment, then add the lookback diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index cf4a80d9705..50ae43df0d0 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -2,6 +2,7 @@ DOMAIN = "stream" ATTR_ENDPOINTS = "endpoints" +ATTR_SETTINGS = "settings" ATTR_STREAMS = "streams" HLS_PROVIDER = "hls" @@ -19,16 +20,15 @@ OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist MAX_SEGMENTS = 5 # Max number of segments to keep around -TARGET_SEGMENT_DURATION = 2.0 # Each segment is about this many seconds -TARGET_PART_DURATION = 1.0 +TARGET_SEGMENT_DURATION_NON_LL_HLS = 2.0 # Each segment is about this many seconds SEGMENT_DURATION_ADJUSTER = 0.1 # Used to avoid missing keyframe boundaries -# Each segment is at least this many seconds -MIN_SEGMENT_DURATION = TARGET_SEGMENT_DURATION - SEGMENT_DURATION_ADJUSTER - # Number of target durations to start before the end of the playlist. # 1.5 should put us in the middle of the second to last segment even with # variable keyframe intervals. -EXT_X_START = 1.5 +EXT_X_START_NON_LL_HLS = 1.5 +# Number of part durations to start before the end of the playlist with LL-HLS +EXT_X_START_LL_HLS = 2 + PACKETS_TO_WAIT_FOR_AUDIO = 20 # Some streams have an audio stream with no audio MAX_TIMESTAMP_GAP = 10000 # seconds - anything from 10 to 50000 is probably reasonable @@ -38,3 +38,7 @@ SOURCE_TIMEOUT = 30 # Timeout for reading stream source STREAM_RESTART_INCREMENT = 10 # Increase wait_timeout by this amount each retry STREAM_RESTART_RESET_TIME = 300 # Reset wait_timeout after this many seconds + +CONF_LL_HLS = "ll_hls" +CONF_PART_DURATION = "part_duration" +CONF_SEGMENT_DURATION = "segment_duration" diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index d840bfaf858..b51c953e915 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio from collections import deque +from collections.abc import Iterable import datetime from typing import TYPE_CHECKING from aiohttp import web +import async_timeout import attr from homeassistant.components.http.view import HomeAssistantView @@ -14,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.util.decorator import Registry -from .const import ATTR_STREAMS, DOMAIN, TARGET_SEGMENT_DURATION +from .const import ATTR_STREAMS, DOMAIN if TYPE_CHECKING: from . import Stream @@ -22,6 +24,17 @@ if TYPE_CHECKING: PROVIDERS = Registry() +@attr.s(slots=True) +class StreamSettings: + """Stream settings.""" + + ll_hls: bool = attr.ib() + min_segment_duration: float = attr.ib() + part_target_duration: float = attr.ib() + hls_advance_part_limit: int = attr.ib() + hls_part_timeout: float = attr.ib() + + @attr.s(slots=True) class Part: """Represent a segment part.""" @@ -36,24 +49,138 @@ class Part: class Segment: """Represent a segment.""" - sequence: int = attr.ib(default=0) + sequence: int = attr.ib() # the init of the mp4 the segment is based on - init: bytes = attr.ib(default=None) - duration: float = attr.ib(default=0) + init: bytes = attr.ib() # For detecting discontinuities across stream restarts - stream_id: int = attr.ib(default=0) + stream_id: int = attr.ib() + start_time: datetime.datetime = attr.ib() + _stream_outputs: Iterable[StreamOutput] = attr.ib() + duration: float = attr.ib(default=0) parts: list[Part] = attr.ib(factory=list) - start_time: datetime.datetime = attr.ib(factory=datetime.datetime.utcnow) + # Store text of this segment's hls playlist for reuse + # Use list[str] for easy appends + hls_playlist_template: list[str] = attr.ib(factory=list) + hls_playlist_parts: list[str] = attr.ib(factory=list) + # Number of playlist parts rendered so far + hls_num_parts_rendered: int = attr.ib(default=0) + # Set to true when all the parts are rendered + hls_playlist_complete: bool = attr.ib(default=False) + + def __attrs_post_init__(self) -> None: + """Run after init.""" + for output in self._stream_outputs: + output.put(self) @property def complete(self) -> bool: """Return whether the Segment is complete.""" return self.duration > 0 - def get_bytes_without_init(self) -> bytes: + @property + def data_size_with_init(self) -> int: + """Return the size of all part data + init in bytes.""" + return len(self.init) + self.data_size + + @property + def data_size(self) -> int: + """Return the size of all part data without init in bytes.""" + return sum(len(part.data) for part in self.parts) + + @callback + def async_add_part( + self, + part: Part, + duration: float, + ) -> None: + """Add a part to the Segment. + + Duration is non zero only for the last part. + """ + self.parts.append(part) + self.duration = duration + for output in self._stream_outputs: + output.part_put() + + def get_data(self) -> bytes: """Return reconstructed data for all parts as bytes, without init.""" return b"".join([part.data for part in self.parts]) + def _render_hls_template(self, last_stream_id: int, render_parts: bool) -> str: + """Render the HLS playlist section for the Segment. + + The Segment may still be in progress. + This method stores intermediate data in hls_playlist_parts, hls_num_parts_rendered, + and hls_playlist_complete to avoid redoing work on subsequent calls. + """ + if self.hls_playlist_complete: + return self.hls_playlist_template[0] + if not self.hls_playlist_template: + # This is a placeholder where the rendered parts will be inserted + self.hls_playlist_template.append("{}") + if render_parts: + for part_num, part in enumerate( + self.parts[self.hls_num_parts_rendered :], self.hls_num_parts_rendered + ): + self.hls_playlist_parts.append( + f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" + f'"./segment/{self.sequence}.{part_num}.m4s"{",INDEPENDENT=YES" if part.has_keyframe else ""}' + ) + if self.complete: + # Construct the final playlist_template. The placeholder will share a line with + # the first element to avoid an extra newline when we don't render any parts. + # Append an empty string to create a trailing newline when we do render parts + self.hls_playlist_parts.append("") + self.hls_playlist_template = [] + # Logically EXT-X-DISCONTINUITY would make sense above the parts, but Apple's + # media stream validator seems to only want it before the segment + if last_stream_id != self.stream_id: + self.hls_playlist_template.append("#EXT-X-DISCONTINUITY") + # Add the remaining segment metadata + self.hls_playlist_template.extend( + [ + "#EXT-X-PROGRAM-DATE-TIME:" + + self.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + "Z", + f"#EXTINF:{self.duration:.3f},\n./segment/{self.sequence}.m4s", + ] + ) + # The placeholder now goes on the same line as the first element + self.hls_playlist_template[0] = "{}" + self.hls_playlist_template[0] + + # Store intermediate playlist data in member variables for reuse + self.hls_playlist_template = ["\n".join(self.hls_playlist_template)] + # lstrip discards extra preceding newline in case first render was empty + self.hls_playlist_parts = ["\n".join(self.hls_playlist_parts).lstrip()] + self.hls_num_parts_rendered = len(self.parts) + self.hls_playlist_complete = self.complete + + return self.hls_playlist_template[0] + + def render_hls( + self, last_stream_id: int, render_parts: bool, add_hint: bool + ) -> str: + """Render the HLS playlist section for the Segment including a hint if requested.""" + playlist_template = self._render_hls_template(last_stream_id, render_parts) + playlist = playlist_template.format( + self.hls_playlist_parts[0] if render_parts else "" + ) + if not add_hint: + return playlist + # Preload hints help save round trips by informing the client about the next part. + # The next part will usually be in this segment but will be first part of the next + # segment if this segment is already complete. + if self.complete: # Next part belongs to next segment + sequence = self.sequence + 1 + part_num = 0 + else: # Next part is in the same segment + sequence = self.sequence + part_num = len(self.parts) + hint = ( + f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{sequence}.{part_num}.m4s"' + ) + return (playlist + "\n" + hint) if playlist else hint + class IdleTimer: """Invoke a callback after an inactivity timeout. @@ -110,6 +237,7 @@ class StreamOutput: self._hass = hass self.idle_timer = idle_timer self._event = asyncio.Event() + self._part_event = asyncio.Event() self._segments: deque[Segment] = deque(maxlen=deque_maxlen) @property @@ -141,13 +269,6 @@ class StreamOutput: return self._segments[-1] return None - @property - def target_duration(self) -> float: - """Return the max duration of any given segment in seconds.""" - if not (durations := [s.duration for s in self._segments if s.complete]): - return TARGET_SEGMENT_DURATION - return max(durations) - def get_segment(self, sequence: int) -> Segment | None: """Retrieve a specific segment.""" # Most hits will come in the most recent segments, so iterate reversed @@ -160,8 +281,23 @@ class StreamOutput: """Retrieve all segments.""" return self._segments + async def part_recv(self, timeout: float | None = None) -> bool: + """Wait for an event signalling the latest part segment.""" + try: + async with async_timeout.timeout(timeout): + await self._part_event.wait() + except asyncio.TimeoutError: + return False + return True + + def part_put(self) -> None: + """Set event signalling the latest part segment.""" + # Start idle timeout when we start receiving data + self._part_event.set() + self._part_event.clear() + async def recv(self) -> bool: - """Wait for and retrieve the latest segment.""" + """Wait for the latest segment.""" await self._event.wait() return self.last_segment is not None @@ -197,7 +333,7 @@ class StreamView(HomeAssistantView): platform = None async def get( - self, request: web.Request, token: str, sequence: str = "" + self, request: web.Request, token: str, sequence: str = "", part_num: str = "" ) -> web.StreamResponse: """Start a GET request.""" hass = request.app["hass"] @@ -213,10 +349,10 @@ class StreamView(HomeAssistantView): # Start worker if not already started stream.start() - return await self.handle(request, stream, sequence) + return await self.handle(request, stream, sequence, part_num) async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.StreamResponse: """Handle the stream request.""" raise NotImplementedError() diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 7f11bc09655..39ea9a5e8c0 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -1,25 +1,31 @@ """Provide functionality to stream HLS.""" from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, cast from aiohttp import web from homeassistant.core import HomeAssistant, callback from .const import ( - EXT_X_START, + ATTR_SETTINGS, + DOMAIN, + EXT_X_START_LL_HLS, + EXT_X_START_NON_LL_HLS, FORMAT_CONTENT_TYPE, HLS_PROVIDER, MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS, ) -from .core import PROVIDERS, IdleTimer, StreamOutput, StreamView +from .core import PROVIDERS, IdleTimer, StreamOutput, StreamSettings, StreamView from .fmp4utils import get_codec_string if TYPE_CHECKING: from . import Stream +_LOGGER = logging.getLogger(__name__) + @callback def async_setup_hls(hass: HomeAssistant) -> str: @@ -28,9 +34,42 @@ def async_setup_hls(hass: HomeAssistant) -> str: hass.http.register_view(HlsSegmentView()) hass.http.register_view(HlsInitView()) hass.http.register_view(HlsMasterPlaylistView()) + hass.http.register_view(HlsPartView()) return "/api/hls/{}/master_playlist.m3u8" +@PROVIDERS.register(HLS_PROVIDER) +class HlsStreamOutput(StreamOutput): + """Represents HLS Output formats.""" + + def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + """Initialize HLS output.""" + super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) + self.stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._target_duration = 0.0 + + @property + def name(self) -> str: + """Return provider name.""" + return HLS_PROVIDER + + @property + def target_duration(self) -> float: + """ + Return the target duration. + + The target duration is calculated as the max duration of any given segment, + and it is calculated only one time to avoid changing during playback. + """ + if self._target_duration: + return self._target_duration + durations = [s.duration for s in self._segments if s.complete] + if len(durations) < 2: + return self.stream_settings.min_segment_duration + self._target_duration = max(durations) + return self._target_duration + + class HlsMasterPlaylistView(StreamView): """Stream view used only for Chromecast compatibility.""" @@ -46,12 +85,7 @@ class HlsMasterPlaylistView(StreamView): # hls spec already allows for 25% variation if not (segment := track.get_segment(track.sequences[-2])): return "" - bandwidth = round( - (len(segment.init) + sum(len(part.data) for part in segment.parts)) - * 8 - / segment.duration - * 1.2 - ) + bandwidth = round(segment.data_size_with_init * 8 / segment.duration * 1.2) codecs = get_codec_string(segment.init) lines = [ "#EXTM3U", @@ -61,7 +95,7 @@ class HlsMasterPlaylistView(StreamView): return "\n".join(lines) + "\n" async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.Response: """Return m3u8 playlist.""" track = stream.add_provider(HLS_PROVIDER) @@ -71,8 +105,14 @@ class HlsMasterPlaylistView(StreamView): return web.HTTPNotFound() if len(track.sequences) == 1 and not await track.recv(): return web.HTTPNotFound() - headers = {"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER]} - return web.Response(body=self.render(track).encode("utf-8"), headers=headers) + response = web.Response( + body=self.render(track).encode("utf-8"), + headers={ + "Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER], + }, + ) + response.enable_compression(web.ContentCoding.gzip) + return response class HlsPlaylistView(StreamView): @@ -82,9 +122,9 @@ class HlsPlaylistView(StreamView): name = "api:stream:hls:playlist" cors_allowed = True - @staticmethod - def render(track: StreamOutput) -> str: - """Render playlist.""" + @classmethod + def render(cls, track: HlsStreamOutput) -> str: + """Render HLS playlist file.""" # NUM_PLAYLIST_SEGMENTS+1 because most recent is probably not yet complete segments = list(track.get_segments())[-(NUM_PLAYLIST_SEGMENTS + 1) :] @@ -102,9 +142,17 @@ class HlsPlaylistView(StreamView): f"#EXT-X-TARGETDURATION:{track.target_duration:.0f}", f"#EXT-X-MEDIA-SEQUENCE:{first_segment.sequence}", f"#EXT-X-DISCONTINUITY-SEQUENCE:{first_segment.stream_id}", - "#EXT-X-PROGRAM-DATE-TIME:" - + first_segment.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", + ] + + if track.stream_settings.ll_hls: + playlist.extend( + [ + f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}", + f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}", + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES", + ] + ) + else: # Since our window doesn't have many segments, we don't want to start # at the beginning or we risk a behind live window exception in Exoplayer. # EXT-X-START is not supposed to be within 3 target durations of the end, @@ -113,47 +161,147 @@ class HlsPlaylistView(StreamView): # don't autoplay. Also, hls.js uses the player parameter liveSyncDuration # which seems to take precedence for setting target delay. Yet it also # doesn't seem to hurt, so we can stick with it for now. - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START * track.target_duration:.3f}", - ] + playlist.append( + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES" + ) last_stream_id = first_segment.stream_id - # Add playlist sections - for segment in segments: - # Skip last segment if it is not complete - if segment.complete: - if last_stream_id != segment.stream_id: - playlist.extend( - [ - "#EXT-X-DISCONTINUITY", - "#EXT-X-PROGRAM-DATE-TIME:" - + segment.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - ] - ) - playlist.extend( - [ - f"#EXTINF:{segment.duration:.3f},", - f"./segment/{segment.sequence}.m4s", - ] + + # Add playlist sections for completed segments + # Enumeration used to only include EXT-X-PART data for last 3 segments. + # The RFC seems to suggest removing parts after 3 full segments, but Apple's + # own example shows removing after 2 full segments and 1 part one. + for i, segment in enumerate(segments[:-1], 3 - len(segments)): + playlist.append( + segment.render_hls( + last_stream_id=last_stream_id, + render_parts=i >= 0 and track.stream_settings.ll_hls, + add_hint=False, ) - last_stream_id = segment.stream_id + ) + last_stream_id = segment.stream_id + + playlist.append( + segments[-1].render_hls( + last_stream_id=last_stream_id, + render_parts=track.stream_settings.ll_hls, + add_hint=track.stream_settings.ll_hls, + ) + ) return "\n".join(playlist) + "\n" + @staticmethod + def bad_request(blocking: bool, target_duration: float) -> web.Response: + """Return a HTTP Bad Request response.""" + return web.Response( + body=None, + status=400, + # From Appendix B.1 of the RFC: + # Successful responses to blocking Playlist requests should be cached + # for six Target Durations. Unsuccessful responses (such as 404s) should + # be cached for four Target Durations. Successful responses to non-blocking + # Playlist requests should be cached for half the Target Duration. + # Unsuccessful responses to non-blocking Playlist requests should be + # cached for for one Target Duration. + headers={ + "Cache-Control": f"max-age={(4 if blocking else 1)*target_duration:.0f}" + }, + ) + + @staticmethod + def not_found(blocking: bool, target_duration: float) -> web.Response: + """Return a HTTP Not Found response.""" + return web.Response( + body=None, + status=404, + headers={ + "Cache-Control": f"max-age={(4 if blocking else 1)*target_duration:.0f}" + }, + ) + async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.Response: """Return m3u8 playlist.""" - track = stream.add_provider(HLS_PROVIDER) + track: HlsStreamOutput = cast( + HlsStreamOutput, stream.add_provider(HLS_PROVIDER) + ) stream.start() - # Make sure at least two segments are ready (last one may not be complete) - if not track.sequences and not await track.recv(): - return web.HTTPNotFound() - if len(track.sequences) == 1 and not await track.recv(): - return web.HTTPNotFound() - headers = {"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER]} + + hls_msn: str | int | None = request.query.get("_HLS_msn") + hls_part: str | int | None = request.query.get("_HLS_part") + blocking_request = bool(hls_msn or hls_part) + + # If the Playlist URI contains an _HLS_part directive but no _HLS_msn + # directive, the Server MUST return Bad Request, such as HTTP 400. + if hls_msn is None and hls_part: + return web.HTTPBadRequest() + + hls_msn = int(hls_msn or 0) + + # If the _HLS_msn is greater than the Media Sequence Number of the last + # Media Segment in the current Playlist plus two, or if the _HLS_part + # exceeds the last Part Segment in the current Playlist by the + # Advance Part Limit, then the server SHOULD immediately return Bad + # Request, such as HTTP 400. + if hls_msn > track.last_sequence + 2: + return self.bad_request(blocking_request, track.target_duration) + + if hls_part is None: + # We need to wait for the whole segment, so effectively the next msn + hls_part = -1 + hls_msn += 1 + else: + hls_part = int(hls_part) + + while hls_msn > track.last_sequence: + if not await track.recv(): + return self.not_found(blocking_request, track.target_duration) + if track.last_segment is None: + return self.not_found(blocking_request, 0) + if ( + (last_segment := track.last_segment) + and hls_msn == last_segment.sequence + and hls_part + >= len(last_segment.parts) + - 1 + + track.stream_settings.hls_advance_part_limit + ): + return self.bad_request(blocking_request, track.target_duration) + + # Receive parts until msn and part are met + while ( + (last_segment := track.last_segment) + and hls_msn == last_segment.sequence + and hls_part >= len(last_segment.parts) + ): + if not await track.part_recv( + timeout=track.stream_settings.hls_part_timeout + ): + return self.not_found(blocking_request, track.target_duration) + # Now we should have msn.part >= hls_msn.hls_part. However, in the case + # that we have a rollover part request from the previous segment, we need + # to make sure that the new segment has a part. From 6.2.5.2 of the RFC: + # If the Client requests a Part Index greater than that of the final + # Partial Segment of the Parent Segment, the Server MUST treat the + # request as one for Part Index 0 of the following Parent Segment. + if hls_msn + 1 == last_segment.sequence: + if not (previous_segment := track.get_segment(hls_msn)) or ( + hls_part >= len(previous_segment.parts) + and not last_segment.parts + and not await track.part_recv( + timeout=track.stream_settings.hls_part_timeout + ) + ): + return self.not_found(blocking_request, track.target_duration) + response = web.Response( - body=self.render(track).encode("utf-8"), headers=headers + body=self.render(track).encode("utf-8"), + headers={ + "Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER], + "Cache-Control": f"max-age={(6 if blocking_request else 0.5)*track.target_duration:.0f}", + }, ) response.enable_compression(web.ContentCoding.gzip) return response @@ -167,14 +315,63 @@ class HlsInitView(StreamView): cors_allowed = True async def handle( - self, request: web.Request, stream: Stream, sequence: str + self, request: web.Request, stream: Stream, sequence: str, part_num: str ) -> web.Response: """Return init.mp4.""" track = stream.add_provider(HLS_PROVIDER) - if not (segments := track.get_segments()): + if not (segments := track.get_segments()) or not (body := segments[0].init): return web.HTTPNotFound() return web.Response( - body=segments[0].init, headers={"Content-Type": "video/mp4"} + body=body, + headers={"Content-Type": "video/mp4"}, + ) + + +class HlsPartView(StreamView): + """Stream view to serve a HLS fmp4 segment.""" + + url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.{part_num:\d+}.m4s" + name = "api:stream:hls:part" + cors_allowed = True + + async def handle( + self, request: web.Request, stream: Stream, sequence: str, part_num: str + ) -> web.Response: + """Handle part.""" + track: HlsStreamOutput = cast( + HlsStreamOutput, stream.add_provider(HLS_PROVIDER) + ) + track.idle_timer.awake() + # Ensure that we have a segment. If the request is from a hint for part 0 + # of a segment, there is a small chance it may have arrived before the + # segment has been put. If this happens, wait for one part and retry. + if not ( + (segment := track.get_segment(int(sequence))) + or ( + await track.part_recv(timeout=track.stream_settings.hls_part_timeout) + and (segment := track.get_segment(int(sequence))) + ) + ): + return web.Response( + body=None, + status=404, + headers={"Cache-Control": f"max-age={track.target_duration:.0f}"}, + ) + # If the part is ready or has been hinted, + if int(part_num) == len(segment.parts): + await track.part_recv(timeout=track.stream_settings.hls_part_timeout) + if int(part_num) >= len(segment.parts): + return web.HTTPRequestRangeNotSatisfiable( + headers={ + "Cache-Control": f"max-age={track.target_duration:.0f}", + } + ) + return web.Response( + body=segment.parts[int(part_num)].data, + headers={ + "Content-Type": "video/iso.segment", + "Cache-Control": f"max-age={6*track.target_duration:.0f}", + }, ) @@ -186,29 +383,32 @@ class HlsSegmentView(StreamView): cors_allowed = True async def handle( - self, request: web.Request, stream: Stream, sequence: str - ) -> web.Response: - """Return fmp4 segment.""" - track = stream.add_provider(HLS_PROVIDER) - track.idle_timer.awake() - if not (segment := track.get_segment(int(sequence))): - return web.HTTPNotFound() - headers = {"Content-Type": "video/iso.segment"} - return web.Response( - body=segment.get_bytes_without_init(), - headers=headers, + self, request: web.Request, stream: Stream, sequence: str, part_num: str + ) -> web.StreamResponse: + """Handle segments.""" + track: HlsStreamOutput = cast( + HlsStreamOutput, stream.add_provider(HLS_PROVIDER) + ) + track.idle_timer.awake() + # Ensure that we have a segment. If the request is from a hint for part 0 + # of a segment, there is a small chance it may have arrived before the + # segment has been put. If this happens, wait for one part and retry. + if not ( + (segment := track.get_segment(int(sequence))) + or ( + await track.part_recv(timeout=track.stream_settings.hls_part_timeout) + and (segment := track.get_segment(int(sequence))) + ) + ): + return web.Response( + body=None, + status=404, + headers={"Cache-Control": f"max-age={track.target_duration:.0f}"}, + ) + return web.Response( + body=segment.get_data(), + headers={ + "Content-Type": "video/iso.segment", + "Cache-Control": f"max-age={6*track.target_duration:.0f}", + }, ) - - -@PROVIDERS.register(HLS_PROVIDER) -class HlsStreamOutput(StreamOutput): - """Represents HLS Output formats.""" - - def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: - """Initialize recorder output.""" - super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) - - @property - def name(self) -> str: - """Return provider name.""" - return HLS_PROVIDER diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 99276d9763c..2fa612e631c 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -57,7 +57,7 @@ def recorder_save_worker(file_out: str, segments: deque[Segment]) -> None: # Open segment source = av.open( - BytesIO(segment.init + segment.get_bytes_without_init()), + BytesIO(segment.init + segment.get_data()), "r", format=SEGMENT_CONTAINER_FORMAT, ) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 039163c6cf5..a576ff6d02b 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -2,26 +2,29 @@ from __future__ import annotations from collections import defaultdict, deque -from collections.abc import Generator, Iterator, Mapping +from collections.abc import Callable, Generator, Iterator, Mapping +import datetime from io import BytesIO import logging from threading import Event -from typing import Any, Callable, cast +from typing import Any, cast import av +from homeassistant.core import HomeAssistant + from . import redact_credentials from .const import ( + ATTR_SETTINGS, AUDIO_CODECS, + DOMAIN, MAX_MISSING_DTS, MAX_TIMESTAMP_GAP, - MIN_SEGMENT_DURATION, PACKETS_TO_WAIT_FOR_AUDIO, SEGMENT_CONTAINER_FORMAT, SOURCE_TIMEOUT, - TARGET_PART_DURATION, ) -from .core import Part, Segment, StreamOutput +from .core import Part, Segment, StreamOutput, StreamSettings _LOGGER = logging.getLogger(__name__) @@ -30,10 +33,13 @@ class SegmentBuffer: """Buffer for writing a sequence of packets to the output as a segment.""" def __init__( - self, outputs_callback: Callable[[], Mapping[str, StreamOutput]] + self, + hass: HomeAssistant, + outputs_callback: Callable[[], Mapping[str, StreamOutput]], ) -> None: """Initialize SegmentBuffer.""" self._stream_id: int = 0 + self._hass = hass self._outputs_callback: Callable[ [], Mapping[str, StreamOutput] ] = outputs_callback @@ -52,10 +58,14 @@ class SegmentBuffer: self._memory_file_pos: int = cast(int, None) self._part_start_dts: int = cast(int, None) self._part_has_keyframe = False + self._stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._start_time = datetime.datetime.utcnow() - @staticmethod def make_new_av( - memory_file: BytesIO, sequence: int, input_vstream: av.video.VideoStream + self, + memory_file: BytesIO, + sequence: int, + input_vstream: av.video.VideoStream, ) -> av.container.OutputContainer: """Make a new av OutputContainer.""" return av.open( @@ -63,19 +73,38 @@ class SegmentBuffer: mode="w", format=SEGMENT_CONTAINER_FORMAT, container_options={ - # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970 - # "cmaf" flag replaces several of the movflags used, but too recent to use for now - "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer", - # Sometimes the first segment begins with negative timestamps, and this setting just - # adjusts the timestamps in the output from that segment to start from 0. Helps from - # having to make some adjustments in test_durations - "avoid_negative_ts": "make_non_negative", - "fragment_index": str(sequence + 1), - "video_track_timescale": str(int(1 / input_vstream.time_base)), - # Create a fragments every TARGET_PART_DURATION. The data from each fragment is stored in - # a "Part" that can be combined with the data from all the other "Part"s, plus an init - # section, to reconstitute the data in a "Segment". - "frag_duration": str(int(TARGET_PART_DURATION * 1e6)), + **{ + # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970 + # "cmaf" flag replaces several of the movflags used, but too recent to use for now + "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer", + # Sometimes the first segment begins with negative timestamps, and this setting just + # adjusts the timestamps in the output from that segment to start from 0. Helps from + # having to make some adjustments in test_durations + "avoid_negative_ts": "make_non_negative", + "fragment_index": str(sequence + 1), + "video_track_timescale": str(int(1 / input_vstream.time_base)), + }, + # Only do extra fragmenting if we are using ll_hls + # Let ffmpeg do the work using frag_duration + # Fragment durations may exceed the 15% allowed variance but it seems ok + **( + { + "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer", + # Create a fragment every TARGET_PART_DURATION. The data from each fragment is stored in + # a "Part" that can be combined with the data from all the other "Part"s, plus an init + # section, to reconstitute the data in a "Segment". + # frag_duration seems to be a minimum threshold for determining part boundaries, so some + # parts may have a higher duration. Since Part Target Duration is used in LL-HLS as a + # maximum threshold for part durations, we scale that number down here by .85 and hope + # that the output part durations stay below the maximum Part Target Duration threshold. + # See https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.4.9 + "frag_duration": str( + self._stream_settings.part_target_duration * 1e6 + ), + } + if self._stream_settings.ll_hls + else {} + ), }, ) @@ -120,7 +149,7 @@ class SegmentBuffer: if ( packet.is_keyframe and (packet.dts - self._segment_start_dts) * packet.time_base - >= MIN_SEGMENT_DURATION + >= self._stream_settings.min_segment_duration ): # Flush segment (also flushes the stub part segment) self.flush(packet, last_part=True) @@ -148,13 +177,16 @@ class SegmentBuffer: sequence=self._sequence, stream_id=self._stream_id, init=self._memory_file.getvalue(), + # Fetch the latest StreamOutputs, which may have changed since the + # worker started. + stream_outputs=self._outputs_callback().values(), + start_time=self._start_time + + datetime.timedelta( + seconds=float(self._segment_start_dts * packet.time_base) + ), ) self._memory_file_pos = self._memory_file.tell() self._part_start_dts = self._segment_start_dts - # Fetch the latest StreamOutputs, which may have changed since the - # worker started. - for stream_output in self._outputs_callback().values(): - stream_output.put(self._segment) else: # These are the ends of the part segments self.flush(packet, last_part=False) @@ -164,27 +196,41 @@ class SegmentBuffer: If last_part is True, also close the segment, give it a duration, and clean up the av_output and memory_file. """ + # In some cases using the current packet's dts (which is the start + # dts of the next part) to calculate the part duration will result in a + # value which exceeds the part_target_duration. This can muck up the + # duration of both this part and the next part. An easy fix is to just + # use the current packet dts and cap it by the part target duration. + current_dts = min( + packet.dts, + self._part_start_dts + + self._stream_settings.part_target_duration / packet.time_base, + ) if last_part: # Closing the av_output will write the remaining buffered data to the # memory_file as a new moof/mdat. self._av_output.close() assert self._segment self._memory_file.seek(self._memory_file_pos) - self._segment.parts.append( + self._hass.loop.call_soon_threadsafe( + self._segment.async_add_part, Part( - duration=float((packet.dts - self._part_start_dts) * packet.time_base), + duration=float((current_dts - self._part_start_dts) * packet.time_base), has_keyframe=self._part_has_keyframe, data=self._memory_file.read(), - ) + ), + float((current_dts - self._segment_start_dts) * packet.time_base) + if last_part + else 0, ) if last_part: - self._segment.duration = float( - (packet.dts - self._segment_start_dts) * packet.time_base - ) + # If we've written the last part, we can close the memory_file. self._memory_file.close() # We don't need the BytesIO object anymore else: + # For the last part, these will get set again elsewhere so we can skip + # setting them here. self._memory_file_pos = self._memory_file.tell() - self._part_start_dts = packet.dts + self._part_start_dts = current_dts self._part_has_keyframe = False def discontinuity(self) -> None: diff --git a/homeassistant/components/subaru/translations/ca.json b/homeassistant/components/subaru/translations/ca.json index 310747f613e..6b83af06bb8 100644 --- a/homeassistant/components/subaru/translations/ca.json +++ b/homeassistant/components/subaru/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3" }, "error": { diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index b612934bfad..266df1f6a3b 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) description = event diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 00c45701423..adf3d07f79e 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -3,162 +3,215 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any -from surepy import Surepy -from surepy.enums import LockState +from surepy import Surepy, SurepyEntity +from surepy.enums import EntityType, Location, LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import ServiceCall from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_FLAP_ID, + ATTR_LOCATION, ATTR_LOCK_STATE, + ATTR_PET_NAME, CONF_FEEDERS, CONF_FLAPS, CONF_PETS, DOMAIN, SERVICE_SET_LOCK_STATE, - SPC, + SERVICE_SET_PET_LOCATION, SURE_API_TIMEOUT, - TOPIC_UPDATE, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = ["binary_sensor", "lock", "sensor"] SCAN_INTERVAL = timedelta(minutes=3) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - vol.All( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_FEEDERS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_FLAPS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_PETS): vol.All(cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - }, - cv.deprecated(CONF_FEEDERS), - cv.deprecated(CONF_FLAPS), - cv.deprecated(CONF_PETS), - cv.deprecated(CONF_SCAN_INTERVAL), + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + vol.All( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FEEDERS): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_FLAPS): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_PETS): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + }, + cv.deprecated(CONF_FEEDERS), + cv.deprecated(CONF_FLAPS), + cv.deprecated(CONF_PETS), + cv.deprecated(CONF_SCAN_INTERVAL), + ) ) - ) - }, + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sure Petcare integration.""" - conf = config[DOMAIN] + 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 + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sure Petcare from a config entry.""" hass.data.setdefault(DOMAIN, {}) try: - surepy = Surepy( - conf[CONF_USERNAME], - conf[CONF_PASSWORD], - auth_token=None, - api_timeout=SURE_API_TIMEOUT, - session=async_get_clientsession(hass), + hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( + entry, + hass, ) - except SurePetcareAuthenticationError: + except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") - return False + raise ConfigEntryAuthFailed from error except SurePetcareError as error: - _LOGGER.error("Unable to connect to surepetcare.io: Wrong %s!", error) - return False + raise ConfigEntryNotReady from error - spc = SurePetcareAPI(hass, surepy) - hass.data[DOMAIN][SPC] = spc + await coordinator.async_config_entry_first_refresh() - await spc.async_update() - - async_track_time_interval(hass, spc.async_update, SCAN_INTERVAL) - - # load platforms - for platform in PLATFORMS: - hass.async_create_task( - hass.helpers.discovery.async_load_platform(platform, DOMAIN, {}, config) - ) - - async def handle_set_lock_state(call): - """Call when setting the lock state.""" - await spc.set_lock_state(call.data[ATTR_FLAP_ID], call.data[ATTR_LOCK_STATE]) - await spc.async_update() + hass.config_entries.async_setup_platforms(entry, PLATFORMS) lock_state_service_schema = vol.Schema( { vol.Required(ATTR_FLAP_ID): vol.All( - cv.positive_int, vol.In(spc.states.keys()) + cv.positive_int, vol.In(coordinator.data.keys()) ), vol.Required(ATTR_LOCK_STATE): vol.All( cv.string, vol.Lower, - vol.In( - [ - LockState.UNLOCKED.name.lower(), - LockState.LOCKED_IN.name.lower(), - LockState.LOCKED_OUT.name.lower(), - LockState.LOCKED_ALL.name.lower(), - ] - ), + vol.In(coordinator.lock_states_callbacks.keys()), ), } ) - hass.services.async_register( DOMAIN, SERVICE_SET_LOCK_STATE, - handle_set_lock_state, + coordinator.handle_set_lock_state, schema=lock_state_service_schema, ) + set_pet_location_schema = vol.Schema( + { + vol.Required(ATTR_PET_NAME): vol.In(coordinator.get_pets().keys()), + vol.Required(ATTR_LOCATION): vol.In( + [ + Location.INSIDE.name.title(), + Location.OUTSIDE.name.title(), + ] + ), + } + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PET_LOCATION, + coordinator.handle_set_pet_location, + schema=set_pet_location_schema, + ) + return True -class SurePetcareAPI: - """Define a generic Sure Petcare object.""" +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) - def __init__(self, hass: HomeAssistant, surepy: Surepy) -> None: - """Initialize the Sure Petcare object.""" - self.hass = hass - self.surepy = surepy - self.states: dict[int, Any] = {} + return unload_ok - async def async_update(self, _: Any = None) -> None: + +class SurePetcareDataCoordinator(DataUpdateCoordinator): + """Handle Surepetcare data.""" + + def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: + """Initialize the data handler.""" + self.surepy = Surepy( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + auth_token=entry.data[CONF_TOKEN], + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(hass), + ) + self.lock_states_callbacks = { + LockState.UNLOCKED.name.lower(): self.surepy.sac.unlock, + LockState.LOCKED_IN.name.lower(): self.surepy.sac.lock_in, + LockState.LOCKED_OUT.name.lower(): self.surepy.sac.lock_out, + LockState.LOCKED_ALL.name.lower(): self.surepy.sac.lock, + } + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[int, SurepyEntity]: """Get the latest data from Sure Petcare.""" - try: - self.states = await self.surepy.get_entities(refresh=True) - except SurePetcareError as error: - _LOGGER.error("Unable to fetch data: %s", error) - return + return await self.surepy.get_entities(refresh=True) + except SurePetcareAuthenticationError as err: + raise ConfigEntryAuthFailed("Invalid username/password") from err + except SurePetcareError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err - async_dispatcher_send(self.hass, TOPIC_UPDATE) + async def handle_set_lock_state(self, call: ServiceCall) -> None: + """Call when setting the lock state.""" + flap_id = call.data[ATTR_FLAP_ID] + state = call.data[ATTR_LOCK_STATE] + await self.lock_states_callbacks[state](flap_id) + await self.async_request_refresh() - async def set_lock_state(self, flap_id: int, state: str) -> None: - """Update the lock state of a flap.""" + def get_pets(self) -> dict[str, int]: + """Get pets.""" + pets = {} + for surepy_entity in self.data.values(): + if surepy_entity.type == EntityType.PET and surepy_entity.name: + pets[surepy_entity.name] = surepy_entity.id + return pets - if state == LockState.UNLOCKED.name.lower(): - await self.surepy.sac.unlock(flap_id) - elif state == LockState.LOCKED_IN.name.lower(): - await self.surepy.sac.lock_in(flap_id) - elif state == LockState.LOCKED_OUT.name.lower(): - await self.surepy.sac.lock_out(flap_id) - elif state == LockState.LOCKED_ALL.name.lower(): - await self.surepy.sac.lock(flap_id) + async def handle_set_pet_location(self, call: ServiceCall) -> None: + """Call when setting the pet location.""" + pet_name = call.data[ATTR_PET_NAME] + location = call.data[ATTR_LOCATION] + device_id = self.get_pets()[pet_name] + await self.surepy.sac.set_pet_location(device_id, Location[location.upper()]) + await self.async_request_refresh() diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 0f536d6135d..a75addb11d3 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -1,10 +1,10 @@ """Support for Sure PetCare Flaps/Pets binary sensors.""" from __future__ import annotations -from abc import abstractmethod -import logging +from typing import cast from surepy.entities import SurepyEntity +from surepy.entities.pet import Pet as SurepyPet from surepy.enums import EntityType, Location from homeassistant.components.binary_sensor import ( @@ -12,27 +12,25 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PRESENCE, BinarySensorEntity, ) -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SurePetcareAPI -from .const import DOMAIN, SPC, TOPIC_UPDATE - -_LOGGER = logging.getLogger(__name__) +from . import SurePetcareDataCoordinator +from .const import DOMAIN +from .entity import SurePetcareEntity -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" - if discovery_info is None: - return - entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] + entities: list[SurePetcareBinarySensor] = [] - spc: SurePetcareAPI = hass.data[DOMAIN][SPC] + coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] - for surepy_entity in spc.states.values(): + for surepy_entity in coordinator.data.values(): # connectivity if surepy_entity.type in [ @@ -41,67 +39,43 @@ async def async_setup_platform( EntityType.FEEDER, EntityType.FELAQUA, ]: - entities.append(DeviceConnectivity(surepy_entity.id, spc)) + entities.append(DeviceConnectivity(surepy_entity.id, coordinator)) elif surepy_entity.type == EntityType.PET: - entities.append(Pet(surepy_entity.id, spc)) + entities.append(Pet(surepy_entity.id, coordinator)) elif surepy_entity.type == EntityType.HUB: - entities.append(Hub(surepy_entity.id, spc)) + entities.append(Hub(surepy_entity.id, coordinator)) - async_add_entities(entities, True) + async_add_entities(entities) -class SurePetcareBinarySensor(BinarySensorEntity): +class SurePetcareBinarySensor(SurePetcareEntity, BinarySensorEntity): """A binary sensor implementation for Sure Petcare Entities.""" - _attr_should_poll = False - def __init__( self, - _id: int, - spc: SurePetcareAPI, - device_class: str, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, ) -> None: """Initialize a Sure Petcare binary sensor.""" + super().__init__(surepetcare_id, coordinator) - self._id = _id - self._spc: SurePetcareAPI = spc - - surepy_entity: SurepyEntity = self._spc.states[self._id] - - # cover special case where a device has no name set - if surepy_entity.name: - name = surepy_entity.name - else: - name = f"Unnamed {surepy_entity.type.name.capitalize()}" - - self._attr_device_class = device_class - self._attr_name = f"{surepy_entity.type.name.capitalize()} {name.capitalize()}" - self._attr_unique_id = f"{surepy_entity.household_id}-{self._id}" - - @abstractmethod - @callback - def _async_update(self) -> None: - """Get the latest data and update the state.""" - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._async_update) - ) - self._async_update() + self._attr_name = self._device_name + self._attr_unique_id = self._device_id class Hub(SurePetcareBinarySensor): """Sure Petcare Hub.""" - def __init__(self, _id: int, spc: SurePetcareAPI) -> None: - """Initialize a Sure Petcare Hub.""" - super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY) + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and bool(self._attr_is_on) @callback - def _async_update(self) -> None: + def _update_attr(self, surepy_entity: SurepyEntity) -> None: """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(state["online"]) if surepy_entity.raw_data(): @@ -113,21 +87,17 @@ class Hub(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) - self.async_write_ha_state() class Pet(SurePetcareBinarySensor): """Sure Petcare Pet.""" - def __init__(self, _id: int, spc: SurePetcareAPI) -> None: - """Initialize a Sure Petcare Pet.""" - super().__init__(_id, spc, DEVICE_CLASS_PRESENCE) + _attr_device_class = DEVICE_CLASS_PRESENCE @callback - def _async_update(self) -> None: + def _update_attr(self, surepy_entity: SurepyEntity) -> None: """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + surepy_entity = cast(SurepyPet, surepy_entity) state = surepy_entity.location try: self._attr_is_on = bool(Location(state.where) == Location.INSIDE) @@ -140,31 +110,27 @@ class Pet(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) - self.async_write_ha_state() class DeviceConnectivity(SurePetcareBinarySensor): """Sure Petcare Device.""" + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + def __init__( self, - _id: int, - spc: SurePetcareAPI, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, ) -> None: """Initialize a Sure Petcare Device.""" - super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY) - self._attr_name = f"{self.name}_connectivity" - self._attr_unique_id = ( - f"{self._spc.states[self._id].household_id}-{self._id}-connectivity" - ) + super().__init__(surepetcare_id, coordinator) + self._attr_name = f"{self._device_name} Connectivity" + self._attr_unique_id = f"{self._device_id}-connectivity" @callback - def _async_update(self) -> None: - """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + def _update_attr(self, surepy_entity: SurepyEntity) -> None: state = surepy_entity.raw_data()["status"] - self._attr_is_on = self._attr_available = bool(state) + self._attr_is_on = bool(state) if state: self._attr_extra_state_attributes = { "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', @@ -172,5 +138,3 @@ class DeviceConnectivity(SurePetcareBinarySensor): } else: self._attr_extra_state_attributes = {} - _LOGGER.debug("%s -> state: %s", self.name, state) - self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py new file mode 100644 index 00000000000..30f20257e8c --- /dev/null +++ b/homeassistant/components/surepetcare/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for Sure Petcare integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import surepy +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, SURE_API_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + surepy_client = surepy.Surepy( + data[CONF_USERNAME], + data[CONF_PASSWORD], + auth_token=None, + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(hass), + ) + + token = await surepy_client.sac.get_token() + + return {CONF_TOKEN: token} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sure Petcare.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._username: str | None = None + + async def async_step_import(self, import_info: dict[str, Any] | None) -> FlowResult: + """Set the config entry up from yaml.""" + return await self.async_step_user(import_info) + + 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=USER_DATA_SCHEMA) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except SurePetcareAuthenticationError: + errors["base"] = "invalid_auth" + except SurePetcareError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + user_input[CONF_TOKEN] = info[CONF_TOKEN] + return self.async_create_entry( + title="Sure Petcare", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._username = config[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors = {} + if user_input is not None: + user_input[CONF_USERNAME] = self._username + try: + await validate_input(self.hass, user_input) + except SurePetcareAuthenticationError: + errors["base"] = "invalid_auth" + except SurePetcareError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + existing_entry = await self.async_set_unique_id( + user_input[CONF_USERNAME].lower() + ) + if existing_entry: + 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=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/surepetcare/const.py b/homeassistant/components/surepetcare/const.py index cb5a78a3c1e..6617137b026 100644 --- a/homeassistant/components/surepetcare/const.py +++ b/homeassistant/components/surepetcare/const.py @@ -1,15 +1,10 @@ """Constants for the Sure Petcare component.""" DOMAIN = "surepetcare" -SPC = "spc" - CONF_FEEDERS = "feeders" CONF_FLAPS = "flaps" CONF_PETS = "pets" -# platforms -TOPIC_UPDATE = f"{DOMAIN}_data_update" - # sure petcare api SURE_API_TIMEOUT = 60 @@ -18,7 +13,10 @@ SURE_BATT_VOLTAGE_FULL = 1.6 # voltage SURE_BATT_VOLTAGE_LOW = 1.25 # voltage SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW -# lock state service +# state service SERVICE_SET_LOCK_STATE = "set_lock_state" +SERVICE_SET_PET_LOCATION = "set_pet_location" ATTR_FLAP_ID = "flap_id" +ATTR_LOCATION = "location" ATTR_LOCK_STATE = "lock_state" +ATTR_PET_NAME = "pet_name" diff --git a/homeassistant/components/surepetcare/entity.py b/homeassistant/components/surepetcare/entity.py new file mode 100644 index 00000000000..f7797b4c166 --- /dev/null +++ b/homeassistant/components/surepetcare/entity.py @@ -0,0 +1,53 @@ +"""Entity for Surepetcare.""" +from __future__ import annotations + +from abc import abstractmethod + +from surepy.entities import SurepyEntity + +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SurePetcareDataCoordinator +from .const import DOMAIN + + +class SurePetcareEntity(CoordinatorEntity): + """An implementation for Sure Petcare Entities.""" + + def __init__( + self, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, + ) -> None: + """Initialize a Sure Petcare entity.""" + super().__init__(coordinator) + + self._id = surepetcare_id + + surepy_entity: SurepyEntity = coordinator.data[surepetcare_id] + + if surepy_entity.name: + self._device_name = surepy_entity.name.capitalize() + else: + self._device_name = surepy_entity.type.name.capitalize().replace("_", " ") + + self._device_id = f"{surepy_entity.household_id}-{surepetcare_id}" + self._attr_device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": "Sure Petcare", + "model": surepy_entity.type.name.capitalize().replace("_", " "), + } + self._update_attr(coordinator.data[surepetcare_id]) + + @abstractmethod + @callback + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + """Update the state and attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and update the state.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py new file mode 100644 index 00000000000..8351eea161b --- /dev/null +++ b/homeassistant/components/surepetcare/lock.py @@ -0,0 +1,110 @@ +"""Support for Sure PetCare Flaps locks.""" +from __future__ import annotations + +import logging +from typing import Any + +from surepy.entities import SurepyEntity +from surepy.enums import EntityType, LockState + +from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SurePetcareDataCoordinator +from .const import DOMAIN +from .entity import SurePetcareEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sure PetCare locks on a config entry.""" + + entities: list[SurePetcareLock] = [] + + coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + + for surepy_entity in coordinator.data.values(): + if surepy_entity.type not in [ + EntityType.CAT_FLAP, + EntityType.PET_FLAP, + ]: + continue + + for lock_state in ( + LockState.LOCKED_IN, + LockState.LOCKED_OUT, + LockState.LOCKED_ALL, + ): + entities.append(SurePetcareLock(surepy_entity.id, coordinator, lock_state)) + + async_add_entities(entities) + + +class SurePetcareLock(SurePetcareEntity, LockEntity): + """A lock implementation for Sure Petcare Entities.""" + + coordinator: SurePetcareDataCoordinator + + def __init__( + self, + surepetcare_id: int, + coordinator: SurePetcareDataCoordinator, + lock_state: LockState, + ) -> None: + """Initialize a Sure Petcare lock.""" + self._lock_state = lock_state.name.lower() + self._available = False + + super().__init__(surepetcare_id, coordinator) + + self._attr_name = f"{self._device_name} {self._lock_state.replace('_', ' ')}" + self._attr_unique_id = f"{self._device_id}-{self._lock_state}" + + @property + def available(self) -> bool: + """Return true if entity is available.""" + return self._available and super().available + + @callback + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + """Update the state.""" + status = surepy_entity.raw_data()["status"] + + self._attr_is_locked = ( + LockState(status["locking"]["mode"]).name.lower() == self._lock_state + ) + + self._available = bool(status.get("online")) + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + if self.state != STATE_UNLOCKED: + return + self._attr_is_locking = True + self.async_write_ha_state() + + try: + await self.coordinator.lock_states_callbacks[self._lock_state](self._id) + self._attr_is_locked = True + finally: + self._attr_is_locking = False + self.async_write_ha_state() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + if self.state != STATE_LOCKED: + return + self._attr_is_unlocking = True + self.async_write_ha_state() + + try: + await self.coordinator.surepy.sac.unlock(self._id) + self._attr_is_locked = False + finally: + self._attr_is_unlocking = False + self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index ee97e1ac627..13def08280a 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -2,7 +2,13 @@ "domain": "surepetcare", "name": "Sure Petcare", "documentation": "https://www.home-assistant.io/integrations/surepetcare", - "codeowners": ["@benleb", "@danielhiversen"], - "requirements": ["surepy==0.7.0"], - "iot_class": "cloud_polling" -} + "codeowners": [ + "@benleb", + "@danielhiversen" + ], + "requirements": [ + "surepy==0.7.2" + ], + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 35d35e9be1f..01d4e9f83aa 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -7,32 +7,28 @@ from surepy.entities import SurepyEntity from surepy.enums import EntityType from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, DEVICE_CLASS_BATTERY, PERCENTAGE -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SurePetcareAPI -from .const import ( - DOMAIN, - SPC, - SURE_BATT_VOLTAGE_DIFF, - SURE_BATT_VOLTAGE_LOW, - TOPIC_UPDATE, -) +from . import SurePetcareDataCoordinator +from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW +from .entity import SurePetcareEntity _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Sure PetCare Flaps sensors.""" - if discovery_info is None: - return - entities: list[SurepyEntity] = [] + entities: list[SureBattery] = [] - spc: SurePetcareAPI = hass.data[DOMAIN][SPC] + coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] - for surepy_entity in spc.states.values(): + for surepy_entity in coordinator.data.values(): if surepy_entity.type in [ EntityType.CAT_FLAP, @@ -40,41 +36,31 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= EntityType.FEEDER, EntityType.FELAQUA, ]: - entities.append(SureBattery(surepy_entity.id, spc)) + entities.append(SureBattery(surepy_entity.id, coordinator)) async_add_entities(entities) -class SureBattery(SensorEntity): +class SureBattery(SurePetcareEntity, SensorEntity): """A sensor implementation for Sure Petcare Entities.""" - _attr_should_poll = False + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, _id: int, spc: SurePetcareAPI) -> None: - """Initialize a Sure Petcare sensor.""" + def __init__( + self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator + ) -> None: + """Initialize a Sure Petcare battery sensor.""" + super().__init__(surepetcare_id, coordinator) - self._id = _id - self._spc: SurePetcareAPI = spc - - surepy_entity: SurepyEntity = self._spc.states[_id] - - self._attr_device_class = DEVICE_CLASS_BATTERY - if surepy_entity.name: - self._attr_name = f"{surepy_entity.type.name.capitalize()} {surepy_entity.name.capitalize()} Battery Level" - else: - self._attr_name = f"{surepy_entity.type.name.capitalize()} Battery Level" - self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_unique_id = ( - f"{surepy_entity.household_id}-{surepy_entity.id}-battery" - ) + self._attr_name = f"{self._device_name} Battery Level" + self._attr_unique_id = f"{self._device_id}-battery" @callback - def _async_update(self) -> None: - """Get the latest data and update the state.""" - surepy_entity = self._spc.states[self._id] + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + """Update the state and attributes.""" state = surepy_entity.raw_data()["status"] - self._attr_available = bool(state) try: per_battery_voltage = state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW @@ -92,12 +78,3 @@ class SureBattery(SensorEntity): } else: self._attr_extra_state_attributes = {} - self.async_write_ha_state() - _LOGGER.debug("%s -> state: %s", self.name, state) - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._async_update) - ) - self._async_update() diff --git a/homeassistant/components/surepetcare/services.yaml b/homeassistant/components/surepetcare/services.yaml index 77887a18b87..fc352aeb6ab 100644 --- a/homeassistant/components/surepetcare/services.yaml +++ b/homeassistant/components/surepetcare/services.yaml @@ -20,3 +20,23 @@ set_lock_state: - 'locked_in' - 'locked_out' - 'unlocked' + +set_pet_location: + name: Set pet location + description: Set pet location + fields: + pet_name: + description: Name of pet + example: My_cat + required: true + selector: + text: + location: + description: Pet location (Inside or Outside) + example: inside + required: true + selector: + select: + options: + - 'Inside' + - 'Outside' diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json new file mode 100644 index 00000000000..f3d4d11008f --- /dev/null +++ b/homeassistant/components/surepetcare/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/surepetcare/translations/ca.json b/homeassistant/components/surepetcare/translations/ca.json new file mode 100644 index 00000000000..5165473860a --- /dev/null +++ b/homeassistant/components/surepetcare/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/cs.json b/homeassistant/components/surepetcare/translations/cs.json new file mode 100644 index 00000000000..b6c00c05389 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "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": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/de.json b/homeassistant/components/surepetcare/translations/de.json new file mode 100644 index 00000000000..14f319fb4d3 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/en.json b/homeassistant/components/surepetcare/translations/en.json new file mode 100644 index 00000000000..a6c0889765f --- /dev/null +++ b/homeassistant/components/surepetcare/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/es.json b/homeassistant/components/surepetcare/translations/es.json new file mode 100644 index 00000000000..3d3945748cb --- /dev/null +++ b/homeassistant/components/surepetcare/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/et.json b/homeassistant/components/surepetcare/translations/et.json new file mode 100644 index 00000000000..74f668d14dc --- /dev/null +++ b/homeassistant/components/surepetcare/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/he.json b/homeassistant/components/surepetcare/translations/he.json similarity index 58% rename from homeassistant/components/tesla/translations/he.json rename to homeassistant/components/surepetcare/translations/he.json index 9f3eeb2fc21..454b7e1ae51 100644 --- a/homeassistant/components/tesla/translations/he.json +++ b/homeassistant/components/surepetcare/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", - "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\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" + "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": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05d3\u05d5\u05d0\"\u05dc" + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } } } diff --git a/homeassistant/components/surepetcare/translations/hu.json b/homeassistant/components/surepetcare/translations/hu.json new file mode 100644 index 00000000000..cc0c820facf --- /dev/null +++ b/homeassistant/components/surepetcare/translations/hu.json @@ -0,0 +1,20 @@ +{ + "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", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/id.json b/homeassistant/components/surepetcare/translations/id.json new file mode 100644 index 00000000000..a346fab8e56 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/it.json b/homeassistant/components/surepetcare/translations/it.json new file mode 100644 index 00000000000..aee18749ab0 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/nl.json b/homeassistant/components/surepetcare/translations/nl.json new file mode 100644 index 00000000000..1dd597d28b4 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/no.json b/homeassistant/components/surepetcare/translations/no.json new file mode 100644 index 00000000000..f34edbd641d --- /dev/null +++ b/homeassistant/components/surepetcare/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/pt-BR.json b/homeassistant/components/surepetcare/translations/pt-BR.json new file mode 100644 index 00000000000..c41610abb32 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falha ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/ru.json b/homeassistant/components/surepetcare/translations/ru.json new file mode 100644 index 00000000000..c31f79d1d04 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/ru.json @@ -0,0 +1,20 @@ +{ + "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.", + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/zh-Hant.json b/homeassistant/components/surepetcare/translations/zh-Hant.json new file mode 100644 index 00000000000..ad4530cb30f --- /dev/null +++ b/homeassistant/components/surepetcare/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "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", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/device_trigger.py b/homeassistant/components/switch/device_trigger.py index b796a31134f..6e4cf2f810e 100644 --- a/homeassistant/components/switch/device_trigger.py +++ b/homeassistant/components/switch/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -22,7 +25,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" return await toggle_entity.async_attach_trigger( diff --git a/homeassistant/components/switch/translations/he.json b/homeassistant/components/switch/translations/he.json index 0b70a69350b..6d41c202beb 100644 --- a/homeassistant/components/switch/translations/he.json +++ b/homeassistant/components/switch/translations/he.json @@ -1,4 +1,19 @@ { + "device_automation": { + "action_type": { + "toggle": "\u05d4\u05d7\u05dc\u05e4\u05ea \u05de\u05e6\u05d1 {entity_name}", + "turn_off": "\u05db\u05d9\u05d1\u05d5\u05d9 {entity_name}", + "turn_on": "\u05d4\u05e4\u05e2\u05dc\u05ea {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", + "is_on": "{entity_name} \u05e4\u05d5\u05e2\u05dc" + }, + "trigger_type": { + "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", + "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc" + } + }, "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index a8768a9cd44..421f6cab866 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -1 +1,122 @@ -"""The switchbot component.""" +"""Support for Switchbot devices.""" +from asyncio import Lock + +import switchbot # pylint: disable=import-error + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SENSOR_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + ATTR_BOT, + ATTR_CURTAIN, + BTLE_LOCK, + COMMON_OPTIONS, + CONF_RETRY_COUNT, + CONF_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT, + CONF_TIME_BETWEEN_UPDATE_COMMAND, + DATA_COORDINATOR, + DEFAULT_RETRY_COUNT, + DEFAULT_RETRY_TIMEOUT, + DEFAULT_SCAN_TIMEOUT, + DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + DOMAIN, +) +from .coordinator import SwitchbotDataUpdateCoordinator + +PLATFORMS_BY_TYPE = { + ATTR_BOT: ["switch", "sensor"], + ATTR_CURTAIN: ["cover", "binary_sensor", "sensor"], +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Switchbot from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + if not entry.options: + options = { + CONF_TIME_BETWEEN_UPDATE_COMMAND: DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT, + CONF_RETRY_TIMEOUT: DEFAULT_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT: DEFAULT_SCAN_TIMEOUT, + } + + hass.config_entries.async_update_entry(entry, options=options) + + # Use same coordinator instance for all entities. + # Uses BTLE advertisement data, all Switchbot devices in range is stored here. + if DATA_COORDINATOR not in hass.data[DOMAIN]: + + # Check if asyncio.lock is stored in hass data. + # BTLE has issues with multiple connections, + # so we use a lock to ensure that only one API request is reaching it at a time: + if BTLE_LOCK not in hass.data[DOMAIN]: + hass.data[DOMAIN][BTLE_LOCK] = Lock() + + if COMMON_OPTIONS not in hass.data[DOMAIN]: + hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} + + switchbot.DEFAULT_RETRY_TIMEOUT = hass.data[DOMAIN][COMMON_OPTIONS][ + CONF_RETRY_TIMEOUT + ] + + # Store api in coordinator. + coordinator = SwitchbotDataUpdateCoordinator( + hass, + update_interval=hass.data[DOMAIN][COMMON_OPTIONS][ + CONF_TIME_BETWEEN_UPDATE_COMMAND + ], + api=switchbot, + retry_count=hass.data[DOMAIN][COMMON_OPTIONS][CONF_RETRY_COUNT], + scan_timeout=hass.data[DOMAIN][COMMON_OPTIONS][CONF_SCAN_TIMEOUT], + api_lock=hass.data[DOMAIN][BTLE_LOCK], + ) + + hass.data[DOMAIN][DATA_COORDINATOR] = coordinator + + else: + coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + + await coordinator.async_config_entry_first_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + sensor_type = entry.data[CONF_SENSOR_TYPE] + + hass.config_entries.async_setup_platforms(entry, PLATFORMS_BY_TYPE[sensor_type]) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + sensor_type = entry.data[CONF_SENSOR_TYPE] + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + if len(hass.config_entries.async_entries(DOMAIN)) == 0: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + # Update entity options stored in hass. + if {**entry.options} != hass.data[DOMAIN][COMMON_OPTIONS]: + hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} + hass.data[DOMAIN].pop(DATA_COORDINATOR) + + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py new file mode 100644 index 00000000000..d58e244d57c --- /dev/null +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -0,0 +1,75 @@ +"""Support for SwitchBot binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 1 + +BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { + "calibration": BinarySensorEntityDescription( + key="calibration", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbot curtain based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + if not coordinator.data[entry.unique_id].get("data"): + return + + async_add_entities( + [ + SwitchBotBinarySensor( + coordinator, + entry.unique_id, + binary_sensor, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + ) + for binary_sensor in coordinator.data[entry.unique_id]["data"] + if binary_sensor in BINARY_SENSOR_TYPES + ] + ) + + +class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity): + """Representation of a Switchbot binary sensor.""" + + coordinator: SwitchbotDataUpdateCoordinator + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + binary_sensor: str, + mac: str, + switchbot_name: str, + ) -> None: + """Initialize the Switchbot sensor.""" + super().__init__(coordinator, idx, mac, name=switchbot_name) + self._sensor = binary_sensor + self._attr_unique_id = f"{idx}-{binary_sensor}" + self._attr_name = f"{switchbot_name} {binary_sensor.title()}" + self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return self.data["data"][self._sensor] diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py new file mode 100644 index 00000000000..2d4e61bada5 --- /dev/null +++ b/homeassistant/components/switchbot/config_flow.py @@ -0,0 +1,200 @@ +"""Config flow for Switchbot.""" +from __future__ import annotations + +from asyncio import Lock +import logging +from typing import Any + +from switchbot import GetSwitchbotDevices # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + BTLE_LOCK, + CONF_RETRY_COUNT, + CONF_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT, + CONF_TIME_BETWEEN_UPDATE_COMMAND, + DEFAULT_RETRY_COUNT, + DEFAULT_RETRY_TIMEOUT, + DEFAULT_SCAN_TIMEOUT, + DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + DOMAIN, + SUPPORTED_MODEL_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +def _btle_connect() -> dict: + """Scan for BTLE advertisement data.""" + + switchbot_devices = GetSwitchbotDevices().discover() + + if not switchbot_devices: + raise NotConnectedError("Failed to discover switchbot") + + return switchbot_devices + + +class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Switchbot.""" + + VERSION = 1 + + async def _get_switchbots(self) -> dict: + """Try to discover nearby Switchbot devices.""" + # asyncio.lock prevents btle adapter exceptions if there are multiple calls to this method. + # store asyncio.lock in hass data if not present. + if DOMAIN not in self.hass.data: + self.hass.data.setdefault(DOMAIN, {}) + if BTLE_LOCK not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][BTLE_LOCK] = Lock() + + connect_lock = self.hass.data[DOMAIN][BTLE_LOCK] + + # Discover switchbots nearby. + async with connect_lock: + _btle_adv_data = await self.hass.async_add_executor_job(_btle_connect) + + return _btle_adv_data + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SwitchbotOptionsFlowHandler: + """Get the options flow for this handler.""" + return SwitchbotOptionsFlowHandler(config_entry) + + def __init__(self): + """Initialize the config flow.""" + self._discovered_devices = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + + errors: dict[str, str] = {} + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_MAC].replace(":", "")) + self._abort_if_unique_id_configured() + + user_input[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[ + self._discovered_devices[self.unique_id]["modelName"] + ] + + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + + try: + self._discovered_devices = await self._get_switchbots() + + except NotConnectedError: + return self.async_abort(reason="cannot_connect") + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + # Get devices already configured. + configured_devices = { + item.data[CONF_MAC] + for item in self._async_current_entries(include_ignore=False) + } + + # Get supported devices not yet configured. + unconfigured_devices = { + device["mac_address"]: f"{device['mac_address']} {device['modelName']}" + for device in self._discovered_devices.values() + if device.get("modelName") in SUPPORTED_MODEL_TYPES + and device["mac_address"] not in configured_devices + } + + if not unconfigured_devices: + return self.async_abort(reason="no_unconfigured_devices") + + data_schema = vol.Schema( + { + vol.Required(CONF_MAC): vol.In(unconfigured_devices), + vol.Required(CONF_NAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Handle config import from yaml.""" + _LOGGER.debug("import config: %s", import_config) + + import_config[CONF_MAC] = import_config[CONF_MAC].replace("-", ":").lower() + + await self.async_set_unique_id(import_config[CONF_MAC].replace(":", "")) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_config[CONF_NAME], data=import_config + ) + + +class SwitchbotOptionsFlowHandler(OptionsFlow): + """Handle Switchbot options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Switchbot options.""" + if user_input is not None: + # Update common entity options for all other entities. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.unique_id != self.config_entry.unique_id: + self.hass.config_entries.async_update_entry( + entry, options=user_input + ) + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_TIME_BETWEEN_UPDATE_COMMAND, + default=self.config_entry.options.get( + CONF_TIME_BETWEEN_UPDATE_COMMAND, + DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, + ), + ): int, + vol.Optional( + CONF_RETRY_COUNT, + default=self.config_entry.options.get( + CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT + ), + ): int, + vol.Optional( + CONF_RETRY_TIMEOUT, + default=self.config_entry.options.get( + CONF_RETRY_TIMEOUT, DEFAULT_RETRY_TIMEOUT + ), + ): int, + vol.Optional( + CONF_SCAN_TIMEOUT, + default=self.config_entry.options.get( + CONF_SCAN_TIMEOUT, DEFAULT_SCAN_TIMEOUT + ), + ): int, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class NotConnectedError(Exception): + """Exception for unable to find device.""" diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py new file mode 100644 index 00000000000..8ca7fadf41c --- /dev/null +++ b/homeassistant/components/switchbot/const.py @@ -0,0 +1,26 @@ +"""Constants for the switchbot integration.""" +DOMAIN = "switchbot" +MANUFACTURER = "switchbot" + +# Config Attributes +ATTR_BOT = "bot" +ATTR_CURTAIN = "curtain" +DEFAULT_NAME = "Switchbot" +SUPPORTED_MODEL_TYPES = {"WoHand": ATTR_BOT, "WoCurtain": ATTR_CURTAIN} + +# Config Defaults +DEFAULT_RETRY_COUNT = 3 +DEFAULT_RETRY_TIMEOUT = 5 +DEFAULT_TIME_BETWEEN_UPDATE_COMMAND = 60 +DEFAULT_SCAN_TIMEOUT = 5 + +# Config Options +CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time" +CONF_RETRY_COUNT = "retry_count" +CONF_RETRY_TIMEOUT = "retry_timeout" +CONF_SCAN_TIMEOUT = "scan_timeout" + +# Data +DATA_COORDINATOR = "coordinator" +BTLE_LOCK = "btle_lock" +COMMON_OPTIONS = "common_options" diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py new file mode 100644 index 00000000000..4976af18809 --- /dev/null +++ b/homeassistant/components/switchbot/coordinator.py @@ -0,0 +1,59 @@ +"""Provides the switchbot DataUpdateCoordinator.""" +from __future__ import annotations + +from asyncio import Lock +from datetime import timedelta +import logging + +import switchbot # pylint: disable=import-error + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching switchbot data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + update_interval: int, + api: switchbot, + retry_count: int, + scan_timeout: int, + api_lock: Lock, + ) -> None: + """Initialize global switchbot data updater.""" + self.switchbot_api = api + self.retry_count = retry_count + self.scan_timeout = scan_timeout + self.update_interval = timedelta(seconds=update_interval) + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=self.update_interval + ) + + self.api_lock = api_lock + + def _update_data(self) -> dict | None: + """Fetch device states from switchbot api.""" + + return self.switchbot_api.GetSwitchbotDevices().discover( + retry=self.retry_count, scan_timeout=self.scan_timeout + ) + + async def _async_update_data(self) -> dict | None: + """Fetch data from switchbot.""" + + async with self.api_lock: + switchbot_data = await self.hass.async_add_executor_job(self._update_data) + + if not switchbot_data: + raise UpdateFailed("Unable to fetch switchbot services data") + + return switchbot_data diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py new file mode 100644 index 00000000000..5582b8b4ed6 --- /dev/null +++ b/homeassistant/components/switchbot/cover.py @@ -0,0 +1,142 @@ +"""Support for SwitchBot curtains.""" +from __future__ import annotations + +import logging +from typing import Any + +from switchbot import SwitchbotCurtain # pylint: disable=import-error + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DEVICE_CLASS_CURTAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_RETRY_COUNT, DATA_COORDINATOR, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +# Initialize the logger +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbot curtain based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + [ + SwitchBotCurtainEntity( + coordinator, + entry.unique_id, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + coordinator.switchbot_api.SwitchbotCurtain( + mac=entry.data[CONF_MAC], + password=entry.data.get(CONF_PASSWORD), + retry_count=entry.options[CONF_RETRY_COUNT], + ), + ) + ] + ) + + +class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + coordinator: SwitchbotDataUpdateCoordinator + _attr_device_class = DEVICE_CLASS_CURTAIN + _attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + _attr_assumed_state = True + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + mac: str, + name: str, + device: SwitchbotCurtain, + ) -> None: + """Initialize the Switchbot.""" + super().__init__(coordinator, idx, mac, name) + self._attr_unique_id = idx + self._attr_is_closed = None + self._device = device + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes[ATTR_CURRENT_POSITION] + self._last_run_success = last_state.attributes["last_run_success"] + self._attr_is_closed = last_state.attributes[ATTR_CURRENT_POSITION] <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the curtain.""" + + _LOGGER.debug("Switchbot to open curtain %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.open) + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the curtain.""" + + _LOGGER.debug("Switchbot to close the curtain %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.close) + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of this device.""" + + _LOGGER.debug("Switchbot to stop %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.stop) + ) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + position = kwargs.get(ATTR_POSITION) + + _LOGGER.debug("Switchbot to move at %d %s", position, self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job( + self._device.set_position, position + ) + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_current_cover_position = self.data["data"]["position"] + self._attr_is_closed = self.data["data"]["position"] <= 20 + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py new file mode 100644 index 00000000000..d6e88174d79 --- /dev/null +++ b/homeassistant/components/switchbot/entity.py @@ -0,0 +1,46 @@ +"""An abstract class common to all Switchbot entities.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import MANUFACTURER +from .coordinator import SwitchbotDataUpdateCoordinator + + +class SwitchbotEntity(CoordinatorEntity, Entity): + """Generic entity encapsulating common features of Switchbot device.""" + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + mac: str, + name: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._last_run_success: bool | None = None + self._idx = idx + self._mac = mac + self._attr_name = name + self._attr_device_info: DeviceInfo = { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, + "name": name, + "model": self.data["modelName"], + "manufacturer": MANUFACTURER, + } + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data for this entity.""" + return self.coordinator.data[self._idx] + + @property + def extra_state_attributes(self) -> Mapping[Any, Any]: + """Return the state attributes.""" + return {"last_run_success": self._last_run_success, "mac_address": self._mac} diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 365f4ce475c..38743981ed5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,8 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.8.0"], - "codeowners": ["@danielhiversen"], + "requirements": ["PySwitchbot==0.11.0"], + "config_flow": true, + "codeowners": ["@danielhiversen", "@RenierM26"], "iot_class": "local_polling" } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py new file mode 100644 index 00000000000..78b078c26b4 --- /dev/null +++ b/homeassistant/components/switchbot/sensor.py @@ -0,0 +1,93 @@ +"""Support for SwitchBot sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_SIGNAL_STRENGTH, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 1 + +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "rssi": SensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + ), + "battery": SensorEntityDescription( + key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + "lightLevel": SensorEntityDescription( + key="lightLevel", + native_unit_of_measurement="Level", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbot sensor based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + if not coordinator.data[entry.unique_id].get("data"): + return + + async_add_entities( + [ + SwitchBotSensor( + coordinator, + entry.unique_id, + sensor, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + ) + for sensor in coordinator.data[entry.unique_id]["data"] + if sensor in SENSOR_TYPES + ] + ) + + +class SwitchBotSensor(SwitchbotEntity, SensorEntity): + """Representation of a Switchbot sensor.""" + + coordinator: SwitchbotDataUpdateCoordinator + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + sensor: str, + mac: str, + switchbot_name: str, + ) -> None: + """Initialize the Switchbot sensor.""" + super().__init__(coordinator, idx, mac, name=switchbot_name) + self._sensor = sensor + self._attr_unique_id = f"{idx}-{sensor}" + self._attr_name = f"{switchbot_name} {sensor.title()}" + self.entity_description = SENSOR_TYPES[sensor] + + @property + def native_value(self) -> str: + """Return the state of the sensor.""" + return self.data["data"][self._sensor] diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json new file mode 100644 index 00000000000..8c308083982 --- /dev/null +++ b/homeassistant/components/switchbot/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Setup Switchbot device", + "data": { + "mac": "Device MAC address", + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": {}, + "abort": { + "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", + "no_unconfigured_devices": "No unconfigured devices found.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "switchbot_unsupported_type": "Unsupported Switchbot Type." + } + }, + "options": { + "step": { + "init": { + "data": { + "update_time": "Time between updates (seconds)", + "retry_count": "Retry count", + "retry_timeout": "Timeout between retries", + "scan_timeout": "How long to scan for advertisement data" + } + } + } + } +} diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 3fcf789da93..22e4bb33f1a 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -1,18 +1,37 @@ -"""Support for Switchbot.""" +"""Support for Switchbot bot.""" from __future__ import annotations +import logging from typing import Any -# pylint: disable=import-error -import switchbot +from switchbot import Switchbot # pylint: disable=import-error import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import ( + DEVICE_CLASS_SWITCH, + PLATFORM_SCHEMA, + SwitchEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_SENSOR_TYPE, + STATE_ON, +) +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.typing import ConfigType, DiscoveryInfoType -DEFAULT_NAME = "Switchbot" +from .const import ATTR_BOT, CONF_RETRY_COUNT, DATA_COORDINATOR, DEFAULT_NAME, DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +# Initialize the logger +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -23,71 +42,121 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Perform the setup for Switchbot devices.""" - name = config.get(CONF_NAME) - mac_addr = config[CONF_MAC] - password = config.get(CONF_PASSWORD) - add_entities([SwitchBot(mac_addr, name, password)]) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import yaml config and initiates config flow for Switchbot devices.""" + + # Check if entry config exists and skips import if it does. + if hass.config_entries.async_entries(DOMAIN): + return + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: config[CONF_NAME], + CONF_PASSWORD: config.get(CONF_PASSWORD, None), + CONF_MAC: config[CONF_MAC].replace("-", ":").lower(), + CONF_SENSOR_TYPE: ATTR_BOT, + }, + ) + ) -class SwitchBot(SwitchEntity, RestoreEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up Switchbot based on a config entry.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + [ + SwitchBotBotEntity( + coordinator, + entry.unique_id, + entry.data[CONF_MAC], + entry.data[CONF_NAME], + coordinator.switchbot_api.Switchbot( + mac=entry.data[CONF_MAC], + password=entry.data.get(CONF_PASSWORD), + retry_count=entry.options[CONF_RETRY_COUNT], + ), + ) + ] + ) + + +class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): """Representation of a Switchbot.""" - def __init__(self, mac, name, password) -> None: + coordinator: SwitchbotDataUpdateCoordinator + _attr_device_class = DEVICE_CLASS_SWITCH + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + idx: str | None, + mac: str, + name: str, + device: Switchbot, + ) -> None: """Initialize the Switchbot.""" + super().__init__(coordinator, idx, mac, name) + self._attr_unique_id = idx + self._device = device - self._state: bool | None = None - self._last_run_success: bool | None = None - self._name = name - self._mac = mac - self._device = switchbot.Switchbot(mac=mac, password=password) - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if not state: + last_state = await self.async_get_last_state() + if not last_state: return - self._state = state.state == "on" + self._attr_is_on = last_state.state == STATE_ON + self._last_run_success = last_state.attributes["last_run_success"] - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" - if self._device.turn_on(): - self._state = True - self._last_run_success = True - else: - self._last_run_success = False + _LOGGER.info("Turn Switchbot bot on %s", self._mac) - def turn_off(self, **kwargs) -> None: + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.turn_on) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" - if self._device.turn_off(): - self._state = False - self._last_run_success = True - else: - self._last_run_success = False + _LOGGER.info("Turn Switchbot bot off %s", self._mac) + + async with self.coordinator.api_lock: + self._last_run_success = bool( + await self.hass.async_add_executor_job(self._device.turn_off) + ) @property def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" - return True + if not self.data["data"]["switchMode"]: + return True + return False @property def is_on(self) -> bool: """Return true if device is on.""" - return bool(self._state) + return self.data["data"]["isOn"] @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._mac.replace(":", "") - - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - - @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict: """Return the state attributes.""" - return {"last_run_success": self._last_run_success} + return { + **super().extra_state_attributes, + "switch_mode": self.data["data"]["switchMode"], + } diff --git a/homeassistant/components/switchbot/translations/ca.json b/homeassistant/components/switchbot/translations/ca.json new file mode 100644 index 00000000000..6409efcbab7 --- /dev/null +++ b/homeassistant/components/switchbot/translations/ca.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "no_unconfigured_devices": "No s'han trobat dispositius no configurats.", + "switchbot_unsupported_type": "Tipus de Switchbot no compatible.", + "unknown": "Error inesperat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Adre\u00e7a MAC del dispositiu", + "name": "Nom", + "password": "Contrasenya" + }, + "title": "Configuraci\u00f3 de dispositiu Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Nombre de reintents", + "retry_timeout": "Temps d'espera entre reintents", + "scan_timeout": "Quant de temps s'ha d'escanejar en busca de dades d'alerta", + "update_time": "Temps entre actualitzacions (segons)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/cs.json b/homeassistant/components/switchbot/translations/cs.json new file mode 100644 index 00000000000..7a44ab78d3b --- /dev/null +++ b/homeassistant/components/switchbot/translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured_device": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "step": { + "user": { + "data": { + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/de.json b/homeassistant/components/switchbot/translations/de.json new file mode 100644 index 00000000000..f499712718e --- /dev/null +++ b/homeassistant/components/switchbot/translations/de.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "no_unconfigured_devices": "Keine unkonfigurierten Ger\u00e4te gefunden.", + "switchbot_unsupported_type": "Nicht unterst\u00fctzter Switchbot-Typ.", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "MAC-Adresse des Ger\u00e4ts", + "name": "Name", + "password": "Passwort" + }, + "title": "Switchbot-Ger\u00e4t einrichten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Anzahl der Wiederholungen", + "retry_timeout": "Zeit\u00fcberschreitung zwischen Wiederholungsversuchen", + "scan_timeout": "Wie lange nach Anzeigendaten suchen", + "update_time": "Zeit zwischen Aktualisierungen (Sekunden)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/en.json b/homeassistant/components/switchbot/translations/en.json new file mode 100644 index 00000000000..4ea3d21de65 --- /dev/null +++ b/homeassistant/components/switchbot/translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Device is already configured", + "cannot_connect": "Failed to connect", + "no_unconfigured_devices": "No unconfigured devices found.", + "switchbot_unsupported_type": "Unsupported Switchbot Type.", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Device MAC address", + "name": "Name", + "password": "Password" + }, + "title": "Setup Switchbot device" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Retry count", + "retry_timeout": "Timeout between retries", + "scan_timeout": "How long to scan for advertisement data", + "update_time": "Time between updates (seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/es.json b/homeassistant/components/switchbot/translations/es.json new file mode 100644 index 00000000000..fe22d91e7f1 --- /dev/null +++ b/homeassistant/components/switchbot/translations/es.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured_device": "El dispositivo ya est\u00e1 configurado", + "switchbot_unsupported_type": "Tipo de Switchbot no compatible.", + "unknown": "Error inesperado" + }, + "error": { + "cannot_connect": "Fall\u00f3 al conectar" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Direcci\u00f3n MAC del dispositivo", + "name": "Nombre", + "password": "Contrase\u00f1a" + }, + "title": "Configurar el dispositivo Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Recuento de reintentos", + "retry_timeout": "Tiempo de espera entre reintentos", + "scan_timeout": "Cu\u00e1nto tiempo se debe buscar datos de anuncio", + "update_time": "Tiempo entre actualizaciones (segundos)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/et.json b/homeassistant/components/switchbot/translations/et.json new file mode 100644 index 00000000000..cc746796195 --- /dev/null +++ b/homeassistant/components/switchbot/translations/et.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "no_unconfigured_devices": "H\u00e4\u00e4lestamata seadmeid ei leitud.", + "switchbot_unsupported_type": "Toetamata Switchboti t\u00fc\u00fcp.", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Seadme MAC-aadress", + "name": "Nimi", + "password": "Salas\u00f5na" + }, + "title": "Switchbot seadme seadistamine" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Korduskatsete arv", + "retry_timeout": "Korduskatsete vaheline aeg", + "scan_timeout": "Kui kaua andmeid otsida", + "update_time": "V\u00e4rskenduste vaheline aeg (sekundites)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/he.json b/homeassistant/components/switchbot/translations/he.json new file mode 100644 index 00000000000..09f62069706 --- /dev/null +++ b/homeassistant/components/switchbot/translations/he.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured_device": "\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", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d4\u05ea\u05e7\u05e0\u05ea \u05d1\u05d5\u05e8\u05e8 \u05db\u05d9\u05d5\u05d5\u05e0\u05d5\u05df" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u05e1\u05e4\u05d9\u05e8\u05ea \u05e0\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05d7\u05d5\u05d6\u05e8\u05d9\u05dd", + "retry_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05d1\u05d9\u05df \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05d7\u05d5\u05d6\u05e8\u05d9\u05dd", + "scan_timeout": "\u05db\u05de\u05d4 \u05d6\u05de\u05df \u05dc\u05e1\u05e8\u05d5\u05e7 \u05e0\u05ea\u05d5\u05e0\u05d9 \u05e4\u05e8\u05e1\u05d5\u05de\u05ea", + "update_time": "\u05d6\u05de\u05df \u05d1\u05d9\u05df \u05e2\u05d3\u05db\u05d5\u05e0\u05d9\u05dd (\u05e9\u05e0\u05d9\u05d5\u05ea)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/hu.json b/homeassistant/components/switchbot/translations/hu.json new file mode 100644 index 00000000000..5af1acb5d35 --- /dev/null +++ b/homeassistant/components/switchbot/translations/hu.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "A csatlakoz\u00e1s sikertelen", + "no_unconfigured_devices": "Nem tal\u00e1lhat\u00f3 konfigur\u00e1latlan eszk\u00f6z.", + "switchbot_unsupported_type": "Nem t\u00e1mogatott Switchbot t\u00edpus.", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "one": "\u00dcres", + "other": "\u00dcres" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Eszk\u00f6z MAC-c\u00edme", + "name": "N\u00e9v", + "password": "Jelsz\u00f3" + }, + "title": "Switchbot eszk\u00f6z be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u00dajrapr\u00f3b\u00e1lkoz\u00e1sok sz\u00e1ma", + "retry_timeout": "\u00dajrapr\u00f3b\u00e1lkoz\u00e1sok k\u00f6z\u00f6tti id\u0151korl\u00e1t", + "scan_timeout": "Mennyi ideig keresse a hirdet\u00e9si adatokat", + "update_time": "Friss\u00edt\u00e9sek k\u00f6z\u00f6tti id\u0151 (m\u00e1sodperc)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/id.json b/homeassistant/components/switchbot/translations/id.json new file mode 100644 index 00000000000..af61966afa5 --- /dev/null +++ b/homeassistant/components/switchbot/translations/id.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured_device": "Perangkat sudah dikonfigurasi", + "switchbot_unsupported_type": "Jenis Switchbot yang tidak didukung.", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Alamat MAC perangkat", + "name": "Nama", + "password": "Kata Sandi" + }, + "title": "Siapkan perangkat Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Jumlah percobaan", + "retry_timeout": "Tenggang waktu antara percobaan ulang", + "scan_timeout": "Berapa lama untuk memindai data iklan", + "update_time": "Waktu antara pembaruan (detik)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/it.json b/homeassistant/components/switchbot/translations/it.json new file mode 100644 index 00000000000..9529450232b --- /dev/null +++ b/homeassistant/components/switchbot/translations/it.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured_device": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi", + "no_unconfigured_devices": "Nessun dispositivo non configurato trovato.", + "switchbot_unsupported_type": "Tipo di Switchbot non supportato.", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "one": "Vuoto", + "other": "Vuoti" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Indirizzo MAC del dispositivo", + "name": "Nome", + "password": "Password" + }, + "title": "Impostare il dispositivo Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Conteggio dei tentativi di ripetizione", + "retry_timeout": "Tempo scaduto tra i tentativi", + "scan_timeout": "Per quanto tempo eseguire la scansione dei dati pubblicitari", + "update_time": "Tempo tra gli aggiornamenti (secondi)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/nl.json b/homeassistant/components/switchbot/translations/nl.json new file mode 100644 index 00000000000..fb1e55f6b9d --- /dev/null +++ b/homeassistant/components/switchbot/translations/nl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken", + "no_unconfigured_devices": "Geen niet-geconfigureerde apparaten gevonden.", + "switchbot_unsupported_type": "Niet-ondersteund Switchbot-type.", + "unknown": "Onverwachte fout" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "MAC-adres apparaat", + "name": "Naam", + "password": "Wachtwoord" + }, + "title": "Switchbot-apparaat instellen" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Aantal herhalingen", + "retry_timeout": "Time-out tussen nieuwe pogingen", + "scan_timeout": "Hoe lang te scannen voor advertentiegegevens", + "update_time": "Tijd tussen updates (seconden)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/no.json b/homeassistant/components/switchbot/translations/no.json new file mode 100644 index 00000000000..4d8cb95061a --- /dev/null +++ b/homeassistant/components/switchbot/translations/no.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "no_unconfigured_devices": "Ingen ukonfigurerte enheter ble funnet.", + "switchbot_unsupported_type": "Switchbot-type st\u00f8ttes ikke.", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Enhetens MAC -adresse", + "name": "Navn", + "password": "Passord" + }, + "title": "Sett opp Switchbot-enhet" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Antall nye fors\u00f8k", + "retry_timeout": "Tidsavbrudd mellom fors\u00f8k", + "scan_timeout": "Hvor lenge skal jeg s\u00f8ke etter annonsedata", + "update_time": "Tid mellom oppdateringer (sekunder)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/ro.json b/homeassistant/components/switchbot/translations/ro.json new file mode 100644 index 00000000000..7668dd5e8e2 --- /dev/null +++ b/homeassistant/components/switchbot/translations/ro.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "switchbot_unsupported_type": "Tipul Switchbot neacceptat." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/ru.json b/homeassistant/components/switchbot/translations/ru.json new file mode 100644 index 00000000000..5eaa1cdbc4f --- /dev/null +++ b/homeassistant/components/switchbot/translations/ru.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "\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.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "no_unconfigured_devices": "\u041d\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e.", + "switchbot_unsupported_type": "\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0439 \u0442\u0438\u043f Switchbot.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "MAC-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Switchbot" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u044b\u0445 \u043f\u043e\u043f\u044b\u0442\u043e\u043a", + "retry_timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 \u043c\u0435\u0436\u0434\u0443 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u044b\u043c\u0438 \u043f\u043e\u043f\u044b\u0442\u043a\u0430\u043c\u0438", + "scan_timeout": "\u041a\u0430\u043a \u0434\u043e\u043b\u0433\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0440\u0435\u043a\u043b\u0430\u043c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "update_time": "\u0412\u0440\u0435\u043c\u044f \u043c\u0435\u0436\u0434\u0443 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043c\u0438 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/zh-Hant.json b/homeassistant/components/switchbot/translations/zh-Hant.json new file mode 100644 index 00000000000..44fe1fe5c54 --- /dev/null +++ b/homeassistant/components/switchbot/translations/zh-Hant.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured_device": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "no_unconfigured_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u8a2d\u5b9a\u88dd\u7f6e\u3002", + "switchbot_unsupported_type": "\u4e0d\u652f\u6301\u7684 Switchbot \u985e\u578b\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "\u88dd\u7f6e MAC \u4f4d\u5740", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc" + }, + "title": "\u8a2d\u5b9a Switchbot \u88dd\u7f6e" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u91cd\u8a66\u6b21\u6578", + "retry_timeout": "\u903e\u6642", + "scan_timeout": "\u6383\u63cf\u5ee3\u544a\u6578\u64da\u7684\u6642\u9593", + "update_time": "\u66f4\u65b0\u9593\u9694\u6642\u9593\uff08\u79d2\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 6a23f1bb453..7be726388f0 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -1,6 +1,7 @@ """The Switcher integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -75,10 +76,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Existing device update device data if device.device_id in hass.data[DOMAIN][DATA_DEVICE]: - wrapper: SwitcherDeviceWrapper = hass.data[DOMAIN][DATA_DEVICE][ + coordinator: SwitcherDataUpdateCoordinator = hass.data[DOMAIN][DATA_DEVICE][ device.device_id ] - wrapper.async_set_updated_data(device) + coordinator.async_set_updated_data(device) return # New device - create device @@ -90,15 +91,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.device_type.hex_rep, ) - wrapper = hass.data[DOMAIN][DATA_DEVICE][ + coordinator = hass.data[DOMAIN][DATA_DEVICE][ device.device_id - ] = SwitcherDeviceWrapper(hass, entry, device) - hass.async_create_task(wrapper.async_setup()) + ] = SwitcherDataUpdateCoordinator(hass, entry, device) + coordinator.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) + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in PLATFORMS + ) + ) discovery_task = hass.data[DOMAIN].pop(DATA_DISCOVERY, None) if discovery_task is not None: @@ -114,25 +119,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def stop_bridge(event: Event) -> None: await async_stop_bridge(hass) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) + entry.async_on_unload( + 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.""" +class SwitcherDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator): + """Switcher device data update coordinator.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase ) -> None: - """Initialize the Switcher device wrapper.""" + """Initialize the Switcher device coordinator.""" super().__init__( hass, _LOGGER, name=device.name, update_interval=timedelta(seconds=MAX_UPDATE_INTERVAL_SEC), ) - self.hass = hass self.entry = entry self.data = device @@ -157,9 +163,10 @@ class SwitcherDeviceWrapper(update_coordinator.DataUpdateCoordinator): """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) + @callback + def async_setup(self) -> None: + """Set up the coordinator.""" + dev_reg = device_registry.async_get(self.hass) dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac_address)}, diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index e070bd52d0d..d694bfee380 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDeviceWrapper +from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD @@ -75,16 +75,16 @@ async def async_setup_entry( """Set up Switcher sensor from config entry.""" @callback - def async_add_sensors(wrapper: SwitcherDeviceWrapper) -> None: + def async_add_sensors(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add sensors from Switcher device.""" - if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: async_add_entities( - SwitcherSensorEntity(wrapper, attribute, info) + SwitcherSensorEntity(coordinator, attribute, info) for attribute, info in POWER_PLUG_SENSORS.items() ) - elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: + elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: async_add_entities( - SwitcherSensorEntity(wrapper, attribute, info) + SwitcherSensorEntity(coordinator, attribute, info) for attribute, info in WATER_HEATER_SENSORS.items() ) @@ -98,30 +98,31 @@ class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): def __init__( self, - wrapper: SwitcherDeviceWrapper, + coordinator: SwitcherDataUpdateCoordinator, attribute: str, description: AttributeDescription, ) -> None: """Initialize the entity.""" - super().__init__(wrapper) - self.wrapper = wrapper + super().__init__(coordinator) self.attribute = attribute # Entity class attributes - self._attr_name = f"{wrapper.name} {description.name}" + self._attr_name = f"{coordinator.name} {description.name}" self._attr_icon = description.icon self._attr_native_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"{wrapper.device_id}-{wrapper.mac_address}-{attribute}" + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" + ) self._attr_device_info = { "connections": { - (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) } } @property def native_value(self) -> StateType: """Return value of sensor.""" - return getattr(self.wrapper.data, self.attribute) # type: ignore[no-any-return] + return getattr(self.coordinator.data, self.attribute) # type: ignore[no-any-return] diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 0eeeb881f45..8b93e422e2a 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDeviceWrapper +from . import SwitcherDataUpdateCoordinator from .const import ( CONF_AUTO_OFF, CONF_TIMER_MINUTES, @@ -69,12 +69,12 @@ async def async_setup_entry( ) @callback - def async_add_switch(wrapper: SwitcherDeviceWrapper) -> None: + def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> 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)]) + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: + async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: + async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -84,18 +84,17 @@ async def async_setup_entry( class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): """Representation of a Switcher switch entity.""" - def __init__(self, wrapper: SwitcherDeviceWrapper) -> None: + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: """Initialize the entity.""" - super().__init__(wrapper) - self.wrapper = wrapper + super().__init__(coordinator) self.control_result: bool | None = None # Entity class attributes - self._attr_name = wrapper.name - self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}" + self._attr_name = coordinator.name + self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = { "connections": { - (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) } } @@ -113,7 +112,7 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): try: async with SwitcherApi( - self.wrapper.data.ip_address, self.wrapper.device_id + self.coordinator.data.ip_address, self.coordinator.data.device_id ) as swapi: response = await getattr(swapi, api)(*args) except (asyncio.TimeoutError, OSError, RuntimeError) as err: @@ -127,7 +126,7 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): args, response or error, ) - self.wrapper.last_update_success = False + self.coordinator.last_update_success = False @property def is_on(self) -> bool: @@ -135,7 +134,7 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): if self.control_result is not None: return self.control_result - return bool(self.wrapper.data.device_state == DeviceState.ON) + return bool(self.coordinator.data.device_state == DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/switcher_kis/translations/es.json b/homeassistant/components/switcher_kis/translations/es.json new file mode 100644 index 00000000000..520df7ee4cd --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/es.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/fr.json b/homeassistant/components/switcher_kis/translations/fr.json index e6e7a3c271f..e9ae4e0b644 100644 --- a/homeassistant/components/switcher_kis/translations/fr.json +++ b/homeassistant/components/switcher_kis/translations/fr.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voulez-vous commencer l'installation ?" + "description": "Voulez-vous commencer la configuration ?" } } } diff --git a/homeassistant/components/switcher_kis/translations/hu.json b/homeassistant/components/switcher_kis/translations/hu.json index c3be866fb85..20237758f11 100644 --- a/homeassistant/components/switcher_kis/translations/hu.json +++ b/homeassistant/components/switcher_kis/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El akarja kezdeni a be\u00e1ll\u00edt\u00e1sokat?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1sokat?" } } } diff --git a/homeassistant/components/switcher_kis/translations/id.json b/homeassistant/components/switcher_kis/translations/id.json new file mode 100644 index 00000000000..223836a8b40 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/id.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/nl.json b/homeassistant/components/switcher_kis/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/switcher_kis/translations/nl.json +++ b/homeassistant/components/switcher_kis/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index b2cc45cf67c..5a35be8aa95 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from aioswitcher.bridge import SwitcherBase, SwitcherBridge diff --git a/homeassistant/components/syncthing/translations/fr.json b/homeassistant/components/syncthing/translations/fr.json index 12486fb5cf2..99c31269565 100644 --- a/homeassistant/components/syncthing/translations/fr.json +++ b/homeassistant/components/syncthing/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "user": { diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index c422bfa6f33..ef3e8c4419d 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -5,14 +5,13 @@ from datetime import timedelta import logging import async_timeout -from pysyncthru import SyncThru +from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -28,22 +27,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp_client.async_get_clientsession(hass) hass.data.setdefault(DOMAIN, {}) - printer = SyncThru(entry.data[CONF_URL], session) + printer = SyncThru( + entry.data[CONF_URL], session, connection_mode=ConnectionMode.API + ) async def async_update_data() -> SyncThru: """Fetch data from the printer.""" try: async with async_timeout.timeout(10): await printer.update() - except ValueError as value_error: + except SyncThruAPINotSupported as api_error: # if an exception is thrown, printer does not support syncthru - raise UpdateFailed( - f"Configured printer at {printer.url} does not respond. " - "Please make sure it supports SyncThru and check your configuration." - ) from value_error + _LOGGER.info( + "Configured printer at %s does not provide SyncThru JSON API", + printer.url, + exc_info=api_error, + ) + raise api_error else: + # if the printer is offline, we raise an UpdateFailed if printer.is_unknown_state(): - raise ConfigEntryNotReady + raise UpdateFailed( + f"Configured printer at {printer.url} does not respond." + ) return printer coordinator: DataUpdateCoordinator = DataUpdateCoordinator( @@ -55,6 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() + if isinstance(coordinator.last_exception, SyncThruAPINotSupported): + # this means that the printer does not support the syncthru JSON API + # and the config should simply be discarded + return False device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 31bf8c97edc..db822f650dc 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -3,7 +3,7 @@ import re from urllib.parse import urlparse -from pysyncthru import SyncThru +from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from url_normalize import url_normalize import voluptuous as vol @@ -109,7 +109,9 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): break session = aiohttp_client.async_get_clientsession(self.hass) - printer = SyncThru(user_input[CONF_URL], session) + printer = SyncThru( + user_input[CONF_URL], session, connection_mode=ConnectionMode.API + ) errors = {} try: await printer.update() @@ -117,7 +119,7 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_NAME] = DEFAULT_NAME_TEMPLATE.format( printer.model() or DEFAULT_MODEL ) - except ValueError: + except SyncThruAPINotSupported: errors[CONF_URL] = "syncthru_not_supported" else: if printer.is_unknown_state(): diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 9fd3c2afe06..37b7ed311cb 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -3,7 +3,7 @@ "name": "Samsung SyncThru Printer", "documentation": "https://www.home-assistant.io/integrations/syncthru", "config_flow": true, - "requirements": ["pysyncthru==0.7.3", "url-normalize==1.4.1"], + "requirements": ["pysyncthru==0.7.10", "url-normalize==1.4.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/syncthru/translations/fr.json b/homeassistant/components/syncthru/translations/fr.json index 6d21912e168..2e1d73688e0 100644 --- a/homeassistant/components/syncthru/translations/fr.json +++ b/homeassistant/components/syncthru/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Appareil d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_url": "URL invalide", diff --git a/homeassistant/components/syncthru/translations/id.json b/homeassistant/components/syncthru/translations/id.json index 54d5e6f5c96..5a79e74a771 100644 --- a/homeassistant/components/syncthru/translations/id.json +++ b/homeassistant/components/syncthru/translations/id.json @@ -8,7 +8,7 @@ "syncthru_not_supported": "Perangkat tidak mendukung SyncThru", "unknown_state": "Status printer tidak diketahui, verifikasi URL dan konektivitas jaringan" }, - "flow_title": "Printer Samsung SyncThru: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0bc88b683b7..a4b03ecce3d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,9 +1,10 @@ """The Synology DSM component.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Callable +from typing import Any import async_timeout from synology_dsm import SynologyDSM @@ -26,14 +27,9 @@ from synology_dsm.exceptions import ( SynologyDSMRequestException, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_MAC, CONF_PASSWORD, @@ -45,7 +41,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import ( @@ -68,7 +64,6 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_VERIFY_SSL, DOMAIN, - ENTITY_ENABLE, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, PLATFORMS, @@ -82,7 +77,7 @@ from .const import ( SYSTEM_LOADED, UNDO_UPDATE_LISTENER, UTILISATION_SENSORS, - EntityInfo, + SynologyDSMEntityDescription, ) CONFIG_SCHEMA = cv.deprecated(DOMAIN) @@ -109,12 +104,12 @@ async def async_setup_entry( # noqa: C901 if "SYNO." in entity_entry.unique_id: return None - entries = { - **STORAGE_DISK_BINARY_SENSORS, - **STORAGE_DISK_SENSORS, - **STORAGE_VOL_SENSORS, - **UTILISATION_SENSORS, - } + entries = ( + *STORAGE_DISK_BINARY_SENSORS, + *STORAGE_DISK_SENSORS, + *STORAGE_VOL_SENSORS, + *UTILISATION_SENSORS, + ) infos = entity_entry.unique_id.split("_") serial = infos.pop(0) label = infos.pop(0) @@ -129,22 +124,22 @@ async def async_setup_entry( # noqa: C901 return None entity_type: str | None = None - for entity_key, entity_attrs in entries.items(): + for description in entries: if ( device_id - and entity_attrs[ATTR_NAME] == "Status" + and description.name == "Status" and "Status" in entity_entry.unique_id and "(Smart)" not in entity_entry.unique_id ): - if "sd" in device_id and "disk" in entity_key: - entity_type = entity_key + if "sd" in device_id and "disk" in description.key: + entity_type = description.key continue - if "volume" in device_id and "volume" in entity_key: - entity_type = entity_key + if "volume" in device_id and "volume" in description.key: + entity_type = description.key continue - if entity_attrs[ATTR_NAME] == label: - entity_type = entity_key + if description.name == label: + entity_type = description.key if entity_type is None: return None @@ -199,27 +194,14 @@ async def async_setup_entry( # noqa: C901 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 + raise ConfigEntryAuthFailed(f"reason: {details}") from err except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: - _LOGGER.debug( - "Unable to connect to DSM '%s' during setup: %s", entry.unique_id, err - ) - raise ConfigEntryNotReady from 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 + raise ConfigEntryNotReady(details) from err hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.unique_id] = { @@ -604,51 +586,25 @@ class SynoApi: class SynologyDSMBaseEntity(CoordinatorEntity): """Representation of a Synology NAS entry.""" + entity_description: SynologyDSMEntityDescription + unique_id: str + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMEntityDescription, ) -> None: """Initialize the Synology DSM entity.""" super().__init__(coordinator) + self.entity_description = description 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[ATTR_NAME]}" - self._class = entity_info[ATTR_DEVICE_CLASS] - self._enable_default = entity_info[ENTITY_ENABLE] - 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: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def icon(self) -> str | None: - """Return the icon.""" - return self._icon - - @property - def device_class(self) -> str | None: - """Return the class of this device.""" - return self._class - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_name = f"{api.network.hostname} {description.name}" + self._attr_unique_id: str = ( + f"{api.information.serial}_{description.api_key}:{description.key}" + ) @property def device_info(self) -> DeviceInfo: @@ -661,14 +617,11 @@ class SynologyDSMBaseEntity(CoordinatorEntity): "sw_version": self._api.information.version_string, } - @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 - async def async_added_to_hass(self) -> None: """Register entity for updates from API.""" - self.async_on_remove(self._api.subscribe(self._api_key, self.unique_id)) + self.async_on_remove( + self._api.subscribe(self.entity_description.api_key, self.unique_id) + ) await super().async_added_to_hass() @@ -678,13 +631,12 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMEntityDescription, device_id: str | None = None, ) -> None: """Initialize the Synology DSM disk or volume entity.""" - super().__init__(api, entity_type, entity_info, coordinator) + super().__init__(api, coordinator, description) self._device_id = device_id self._device_name: str | None = None self._device_manufacturer: str | None = None @@ -692,7 +644,7 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): self._device_firmware: str | None = None self._device_type = None - if "volume" in entity_type: + if "volume" in description.key: volume = self._api.storage.get_volume(self._device_id) # Volume does not have a name self._device_name = volume["id"].replace("_", " ").capitalize() @@ -705,7 +657,7 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): .replace("raid", "RAID") .replace("shr", "SHR") ) - elif "disk" in entity_type: + elif "disk" in description.key: disk = self._api.storage.get_disk(self._device_id) self._device_name = disk["name"] self._device_manufacturer = disk["vendor"] @@ -713,9 +665,9 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): self._device_firmware = disk["firm"] self._device_type = disk["diskType"] self._name = ( - f"{self._api.network.hostname} {self._device_name} {entity_info[ATTR_NAME]}" + f"{self._api.network.hostname} {self._device_name} {description.name}" ) - self._unique_id += f"_{self._device_id}" + self._attr_unique_id += f"_{self._device_id}" @property def available(self) -> bool: diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 5f27aa3b038..7f0704790e1 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -1,11 +1,15 @@ """Support for Synology DSM binary sensors.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity, SynologyDSMDeviceEntity from .const import ( @@ -15,6 +19,7 @@ from .const import ( STORAGE_DISK_BINARY_SENSORS, SYNO_API, UPGRADE_BINARY_SENSORS, + SynologyDSMBinarySensorEntityDescription, ) @@ -32,39 +37,52 @@ async def async_setup_entry( | SynoDSMUpgradeBinarySensor | SynoDSMStorageBinarySensor ] = [ - SynoDSMSecurityBinarySensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in SECURITY_BINARY_SENSORS.items() + SynoDSMSecurityBinarySensor(api, coordinator, description) + for description in SECURITY_BINARY_SENSORS ] - entities += [ - SynoDSMUpgradeBinarySensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in UPGRADE_BINARY_SENSORS.items() - ] + entities.extend( + [ + SynoDSMUpgradeBinarySensor(api, coordinator, description) + for description in UPGRADE_BINARY_SENSORS + ] + ) # Handle all disks if api.storage.disks_ids: - for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): - entities += [ - SynoDSMStorageBinarySensor( - api, - sensor_type, - sensor, - coordinator, - disk, - ) - for sensor_type, sensor in STORAGE_DISK_BINARY_SENSORS.items() + entities.extend( + [ + SynoDSMStorageBinarySensor(api, coordinator, description, disk) + for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids) + for description in STORAGE_DISK_BINARY_SENSORS ] + ) async_add_entities(entities) -class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): +class SynoDSMBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): + """Mixin for binary sensor specific attributes.""" + + entity_description: SynologyDSMBinarySensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMBinarySensorEntityDescription, + ) -> None: + """Initialize the Synology DSM binary_sensor entity.""" + super().__init__(api, coordinator, description) + + +class SynoDSMSecurityBinarySensor(SynoDSMBinarySensor): """Representation a Synology Security binary sensor.""" @property def is_on(self) -> bool: """Return the state.""" - return getattr(self._api.security, self.entity_type) != "safe" # type: ignore[no-any-return] + return getattr(self._api.security, self.entity_description.key) != "safe" # type: ignore[no-any-return] @property def available(self) -> bool: @@ -77,24 +95,46 @@ class SynoDSMSecurityBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): return self._api.security.status_by_check # type: ignore[no-any-return] -class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity): +class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, SynoDSMBinarySensor): """Representation a Synology Storage binary sensor.""" + entity_description: SynologyDSMBinarySensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMBinarySensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM storage binary_sensor entity.""" + super().__init__(api, coordinator, description, device_id) + @property def is_on(self) -> bool: """Return the state.""" - return bool(getattr(self._api.storage, self.entity_type)(self._device_id)) + return bool( + getattr(self._api.storage, self.entity_description.key)(self._device_id) + ) -class SynoDSMUpgradeBinarySensor(SynologyDSMBaseEntity, BinarySensorEntity): +class SynoDSMUpgradeBinarySensor(SynoDSMBinarySensor): """Representation a Synology Upgrade binary sensor.""" @property def is_on(self) -> bool: """Return the state.""" - return bool(getattr(self._api.upgrade, self.entity_type)) + return bool(getattr(self._api.upgrade, self.entity_description.key)) @property def available(self) -> bool: """Return True if entity is available.""" return bool(self._api.upgrade) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return firmware details.""" + return { + "installed_version": self._api.information.version_string, + "latest_available_version": self._api.upgrade.available_version, + } diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index d609a434ae2..c305d11f4e0 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -1,6 +1,7 @@ """Support for Synology DSM cameras.""" from __future__ import annotations +from dataclasses import dataclass import logging from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation @@ -9,26 +10,30 @@ from synology_dsm.exceptions import ( SynologyDSMRequestException, ) -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.components.camera import ( + SUPPORT_STREAM, + Camera, + CameraEntityDescription, ) +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 DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity -from .const import COORDINATOR_CAMERAS, DOMAIN, ENTITY_ENABLE, SYNO_API +from .const import COORDINATOR_CAMERAS, DOMAIN, SYNO_API, SynologyDSMEntityDescription _LOGGER = logging.getLogger(__name__) +@dataclass +class SynologyDSMCameraEntityDescription( + CameraEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM camera entity.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -56,6 +61,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Representation a Synology camera.""" coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]] + entity_description: SynologyDSMCameraEntityDescription def __init__( self, @@ -64,26 +70,21 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): camera_id: str, ) -> None: """Initialize a Synology camera.""" - super().__init__( - api, - f"{SynoSurveillanceStation.CAMERA_API_KEY}:{camera_id}", - { - ATTR_NAME: coordinator.data["cameras"][camera_id].name, - ENTITY_ENABLE: coordinator.data["cameras"][camera_id].is_enabled, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: None, - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_STATE_CLASS: None, - }, - coordinator, + description = SynologyDSMCameraEntityDescription( + api_key=SynoSurveillanceStation.CAMERA_API_KEY, + key=camera_id, + name=coordinator.data["cameras"][camera_id].name, + entity_registry_enabled_default=coordinator.data["cameras"][ + camera_id + ].is_enabled, ) + super().__init__(api, coordinator, description) Camera.__init__(self) - self._camera_id = camera_id @property def camera_data(self) -> SynoCamera: """Camera data.""" - return self.coordinator.data["cameras"][self._camera_id] + return self.coordinator.data["cameras"][self.entity_description.key] @property def device_info(self) -> DeviceInfo: @@ -134,7 +135,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): if not self.available: return None try: - return self._api.surveillance_station.get_camera_image(self._camera_id) # type: ignore[no-any-return] + return self._api.surveillance_station.get_camera_image(self.entity_description.key) # type: ignore[no-any-return] except ( SynologyDSMAPIErrorException, SynologyDSMRequestException, @@ -163,7 +164,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): "SynoDSMCamera.enable_motion_detection(%s)", self.camera_data.name, ) - self._api.surveillance_station.enable_motion_detection(self._camera_id) + self._api.surveillance_station.enable_motion_detection( + self.entity_description.key + ) def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" @@ -171,4 +174,6 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): "SynoDSMCamera.disable_motion_detection(%s)", self.camera_data.name, ) - self._api.surveillance_station.disable_motion_detection(self._camera_id) + self._api.surveillance_station.disable_motion_detection( + self.entity_description.key + ) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 97f9e4343fa..ed0ee8e9125 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -46,7 +46,6 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, - EXCEPTION_DETAILS, ) _LOGGER = logging.getLogger(__name__) @@ -58,11 +57,11 @@ 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: +def _reauth_schema() -> 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, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -113,8 +112,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.reauth_conf: dict[str, Any] = {} self.reauth_reason: str | None = None - async def _show_setup_form( + def _show_form( self, + step_id: str, user_input: dict[str, Any] | None = None, errors: dict[str, str] | None = None, ) -> FlowResult: @@ -123,19 +123,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): user_input = {} description_placeholders = {} + data_schema = {} - if self.discovered_conf: + if step_id == "link": 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" + elif step_id == "reauth_confirm": + data_schema = _reauth_schema() + elif step_id == "user": data_schema = _user_schema_with_defaults(user_input) return self.async_show_form( @@ -145,27 +141,10 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): description_placeholders=description_placeholders, ) - async def async_step_user( - self, user_input: dict[str, Any] | None = None + async def async_validate_input_create_entry( + self, user_input: dict[str, Any], step_id: str ) -> FlowResult: - """Handle a flow initiated by the user.""" - errors = {} - - if user_input is None: - return await self._show_setup_form(user_input, None) - - 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) - + """Process user input and create new or update existing config entry.""" host = user_input[CONF_HOST] port = user_input.get(CONF_PORT) username = user_input[CONF_USERNAME] @@ -184,6 +163,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): host, port, username, password, use_ssl, verify_ssl, timeout=30 ) + errors = {} try: serial = await self.hass.async_add_executor_job( _login_and_fetch_syno_info, api, otp_code @@ -207,7 +187,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "missing_data" if errors: - return await self._show_setup_form(user_input, errors) + return self._show_form(step_id, user_input, errors) # unique_id should be serial for services purpose existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) @@ -228,17 +208,26 @@ 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: + if existing_entry: 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") + if self.reauth_conf: + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reconfigure_successful") return self.async_create_entry(title=host, data=config_data) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + step = "user" + if not user_input: + return self._show_form(step) + return await self.async_validate_input_create_entry(user_input, step_id=step) + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a discovered synology_dsm.""" parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) @@ -246,35 +235,63 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() ) - mac = discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() + discovered_mac = discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. - if self._mac_already_configured(mac): - return self.async_abort(reason="already_configured") - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(discovered_mac) + existing_entry = self._async_get_existing_entry(discovered_mac) + + if not existing_entry: + self._abort_if_unique_id_configured() + + if existing_entry and existing_entry.data[CONF_HOST] != parsed_url.hostname: + _LOGGER.debug( + "Update host from '%s' to '%s' for NAS '%s' via SSDP discovery", + existing_entry.data[CONF_HOST], + parsed_url.hostname, + existing_entry.unique_id, + ) + self.hass.config_entries.async_update_entry( + existing_entry, + data={**existing_entry.data, CONF_HOST: parsed_url.hostname}, + ) + return self.async_abort(reason="reconfigure_successful") + + if existing_entry: + return self.async_abort(reason="already_configured") self.discovered_conf = { CONF_NAME: friendly_name, CONF_HOST: parsed_url.hostname, } self.context["title_placeholders"] = self.discovered_conf - return await self.async_step_user() + return await self.async_step_link() - async def async_step_reauth( + async def async_step_link( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """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: """Link a config entry from discovery.""" - return await self.async_step_user(user_input) + step = "link" + if not user_input: + return self._show_form(step) + user_input = {**self.discovered_conf, **user_input} + return await self.async_validate_input_create_entry(user_input, step_id=step) + + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_conf = data.copy() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth confirm upon an API authentication error.""" + step = "reauth_confirm" + if not user_input: + return self._show_form(step) + user_input = {**self.reauth_conf, **user_input} + return await self.async_validate_input_create_entry(user_input, step_id=step) async def async_step_2sa( self, user_input: dict[str, Any], errors: dict[str, str] | None = None @@ -295,14 +312,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) - def _mac_already_configured(self, mac: str) -> bool: - """See if we already have configured a NAS with this MAC address.""" - existing_macs = [ - mac.replace("-", "") - for entry in self._async_current_entries() - for mac in entry.data.get(CONF_MAC, []) - ] - return mac in existing_macs + def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: + """See if we already have a configured NAS with this MAC address.""" + for entry in self._async_current_entries(): + if discovered_mac in [ + mac.replace("-", "") for mac in entry.data.get(CONF_MAC, []) + ]: + return entry + return None class SynologyDSMOptionsFlowHandler(OptionsFlow): diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 1fc6ba6e09b..e054a9594a0 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -1,7 +1,7 @@ """Constants for Synology DSM.""" from __future__ import annotations -from typing import Final, TypedDict +from dataclasses import dataclass from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.upgrade import SynoCoreUpgrade @@ -13,13 +13,14 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.binary_sensor import ( DEVICE_CLASS_SAFETY, DEVICE_CLASS_UPDATE, + BinarySensorEntityDescription, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) +from homeassistant.components.switch import SwitchEntityDescription from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, DATA_TERABYTES, @@ -28,18 +29,7 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) - - -class EntityInfo(TypedDict): - """TypedDict for EntityInfo.""" - - name: str - unit_of_measurement: str | None - icon: str | None - device_class: str | None - state_class: str | None - enable: bool - +from homeassistant.helpers.entity import EntityDescription DOMAIN = "synology_dsm" PLATFORMS = ["binary_sensor", "camera", "sensor", "switch"] @@ -68,7 +58,6 @@ DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = 10 # sec ENTITY_UNIT_LOAD = "load" -ENTITY_ENABLE: Final = "enable" # Services SERVICE_REBOOT = "reboot" @@ -78,285 +67,302 @@ SERVICES = [ SERVICE_SHUTDOWN, ] -# Entity keys should start with the API_KEY to fetch + +@dataclass +class SynologyDSMRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class SynologyDSMEntityDescription(EntityDescription, SynologyDSMRequiredKeysMixin): + """Generic Synology DSM entity description.""" + + +@dataclass +class SynologyDSMBinarySensorEntityDescription( + BinarySensorEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM binary sensor entity.""" + + +@dataclass +class SynologyDSMSensorEntityDescription( + SensorEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM sensor entity.""" + + +@dataclass +class SynologyDSMSwitchEntityDescription( + SwitchEntityDescription, SynologyDSMEntityDescription +): + """Describes Synology DSM switch entity.""" + # Binary sensors -UPGRADE_BINARY_SENSORS: dict[str, EntityInfo] = { - f"{SynoCoreUpgrade.API_KEY}:update_available": { - ATTR_NAME: "Update available", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_UPDATE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, -} +UPGRADE_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( + SynologyDSMBinarySensorEntityDescription( + api_key=SynoCoreUpgrade.API_KEY, + key="update_available", + name="Update available", + device_class=DEVICE_CLASS_UPDATE, + ), +) -SECURITY_BINARY_SENSORS: dict[str, EntityInfo] = { - f"{SynoCoreSecurity.API_KEY}:status": { - 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, - }, -} +SECURITY_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( + SynologyDSMBinarySensorEntityDescription( + api_key=SynoCoreSecurity.API_KEY, + key="status", + name="Security status", + device_class=DEVICE_CLASS_SAFETY, + ), +) -STORAGE_DISK_BINARY_SENSORS: dict[str, EntityInfo] = { - f"{SynoStorage.API_KEY}:disk_exceed_bad_sector_thr": { - 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": { - 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, - }, -} +STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( + SynologyDSMBinarySensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_exceed_bad_sector_thr", + name="Exceeded Max Bad Sectors", + device_class=DEVICE_CLASS_SAFETY, + ), + SynologyDSMBinarySensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_below_remain_life_thr", + name="Below Min Remaining Life", + device_class=DEVICE_CLASS_SAFETY, + ), +) # Sensors -UTILISATION_SENSORS: dict[str, EntityInfo] = { - f"{SynoCoreUtilization.API_KEY}:cpu_other_load": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - 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": { - ATTR_NAME: "Upload Throughput", - 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": { - ATTR_NAME: "Download Throughput", - 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": { - 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": { - 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": { - 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": { - 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": { - ATTR_NAME: "Average Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: None, - }, - f"{SynoStorage.API_KEY}:volume_disk_temp_max": { - ATTR_NAME: "Maximum Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - 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": { - 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": { - 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": { - ATTR_NAME: "Temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, -} +UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_other_load", + name="CPU Utilization (Other)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_user_load", + name="CPU Utilization (User)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_system_load", + name="CPU Utilization (System)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_total_load", + name="CPU Utilization (Total)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chip", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_1min_load", + name="CPU Load Average (1 min)", + native_unit_of_measurement=ENTITY_UNIT_LOAD, + icon="mdi:chip", + entity_registry_enabled_default=False, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_5min_load", + name="CPU Load Average (5 min)", + native_unit_of_measurement=ENTITY_UNIT_LOAD, + icon="mdi:chip", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="cpu_15min_load", + name="CPU Load Average (15 min)", + native_unit_of_measurement=ENTITY_UNIT_LOAD, + icon="mdi:chip", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_real_usage", + name="Memory Usage (Real)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_size", + name="Memory Size", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_cached", + name="Memory Cached", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_available_swap", + name="Memory Available (Swap)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_available_real", + name="Memory Available (Real)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_total_swap", + name="Memory Total (Swap)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="memory_total_real", + name="Memory Total (Real)", + native_unit_of_measurement=DATA_MEGABYTES, + icon="mdi:memory", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="network_up", + name="Upload Throughput", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreUtilization.API_KEY, + key="network_down", + name="Download Throughput", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download", + state_class=STATE_CLASS_MEASUREMENT, + ), +) +STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_status", + name="Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_size_total", + name="Total Size", + native_unit_of_measurement=DATA_TERABYTES, + icon="mdi:chart-pie", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_size_used", + name="Used Space", + native_unit_of_measurement=DATA_TERABYTES, + icon="mdi:chart-pie", + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_percentage_used", + name="Volume Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:chart-pie", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_disk_temp_avg", + name="Average Disk Temp", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="volume_disk_temp_max", + name="Maximum Disk Temp", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + ), +) +STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_smart_status", + name="Status (Smart)", + icon="mdi:checkbox-marked-circle-outline", + entity_registry_enabled_default=False, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_status", + name="Status", + icon="mdi:checkbox-marked-circle-outline", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoStorage.API_KEY, + key="disk_temp", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), +) -INFORMATION_SENSORS: dict[str, EntityInfo] = { - f"{SynoDSMInformation.API_KEY}:temperature": { - ATTR_NAME: "temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_ICON: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ENTITY_ENABLE: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - f"{SynoDSMInformation.API_KEY}:uptime": { - 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, - }, -} +INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoDSMInformation.API_KEY, + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoDSMInformation.API_KEY, + key="uptime", + name="last boot", + device_class=DEVICE_CLASS_TIMESTAMP, + entity_registry_enabled_default=False, + ), +) # Switch -SURVEILLANCE_SWITCH: dict[str, EntityInfo] = { - f"{SynoSurveillanceStation.HOME_MODE_API_KEY}:home_mode": { - 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, - }, -} +SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( + SynologyDSMSwitchEntityDescription( + api_key=SynoSurveillanceStation.HOME_MODE_API_KEY, + key="home_mode", + name="home mode", + icon="mdi:home-account", + ), +) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 72ddb944b11..1aa7e35d992 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -28,7 +28,7 @@ from .const import ( STORAGE_VOL_SENSORS, SYNO_API, UTILISATION_SENSORS, - EntityInfo, + SynologyDSMSensorEntityDescription, ) @@ -42,77 +42,77 @@ async def async_setup_entry( coordinator = data[COORDINATOR_CENTRAL] entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ - SynoDSMUtilSensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in UTILISATION_SENSORS.items() + SynoDSMUtilSensor(api, coordinator, description) + for description in UTILISATION_SENSORS ] # Handle all volumes if api.storage.volumes_ids: - for volume in entry.data.get(CONF_VOLUMES, api.storage.volumes_ids): - entities += [ - SynoDSMStorageSensor( - api, - sensor_type, - sensor, - coordinator, - volume, - ) - for sensor_type, sensor in STORAGE_VOL_SENSORS.items() + entities.extend( + [ + SynoDSMStorageSensor(api, coordinator, description, volume) + for volume in entry.data.get(CONF_VOLUMES, api.storage.volumes_ids) + for description in STORAGE_VOL_SENSORS ] + ) # Handle all disks if api.storage.disks_ids: - for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids): - entities += [ - SynoDSMStorageSensor( - api, - sensor_type, - sensor, - coordinator, - disk, - ) - for sensor_type, sensor in STORAGE_DISK_SENSORS.items() + entities.extend( + [ + SynoDSMStorageSensor(api, coordinator, description, disk) + for disk in entry.data.get(CONF_DISKS, api.storage.disks_ids) + for description in STORAGE_DISK_SENSORS ] + ) - entities += [ - SynoDSMInfoSensor(api, sensor_type, sensor, coordinator) - for sensor_type, sensor in INFORMATION_SENSORS.items() - ] + entities.extend( + [ + SynoDSMInfoSensor(api, coordinator, description) + for description in INFORMATION_SENSORS + ] + ) async_add_entities(entities) -class SynoDSMSensor(SynologyDSMBaseEntity): +class SynoDSMSensor(SynologyDSMBaseEntity, SensorEntity): """Mixin for sensor specific attributes.""" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._unit + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMSensorEntityDescription, + ) -> None: + """Initialize the Synology DSM sensor entity.""" + super().__init__(api, coordinator, description) -class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): +class SynoDSMUtilSensor(SynoDSMSensor): """Representation a Synology Utilisation sensor.""" @property def native_value(self) -> Any | None: """Return the state.""" - attr = getattr(self._api.utilisation, self.entity_type) + attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): attr = attr() if attr is None: return None # Data (RAM) - if self._unit == DATA_MEGABYTES: + if self.native_unit_of_measurement == DATA_MEGABYTES: return round(attr / 1024.0 ** 2, 1) # Network - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: + if self.native_unit_of_measurement == DATA_RATE_KILOBYTES_PER_SECOND: return round(attr / 1024.0, 1) # CPU load average - if self._unit == ENTITY_UNIT_LOAD: + if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: return round(attr / 100, 2) return attr @@ -123,46 +123,57 @@ class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): return bool(self._api.utilisation) -class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity): +class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): """Representation a Synology Storage sensor.""" + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMSensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM storage sensor entity.""" + super().__init__(api, coordinator, description, device_id) + @property def native_value(self) -> Any | None: """Return the state.""" - attr = getattr(self._api.storage, self.entity_type)(self._device_id) + attr = getattr(self._api.storage, self.entity_description.key)(self._device_id) if attr is None: return None # Data (disk space) - if self._unit == DATA_TERABYTES: + if self.native_unit_of_measurement == DATA_TERABYTES: return round(attr / 1024.0 ** 4, 2) return attr -class SynoDSMInfoSensor(SynoDSMSensor, SensorEntity): +class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + description: SynologyDSMSensorEntityDescription, ) -> None: """Initialize the Synology SynoDSMInfoSensor entity.""" - super().__init__(api, entity_type, entity_info, coordinator) + super().__init__(api, coordinator, description) self._previous_uptime: str | None = None self._last_boot: str | None = None @property def native_value(self) -> Any | None: """Return the state.""" - attr = getattr(self._api.information, self.entity_type) + attr = getattr(self._api.information, self.entity_description.key) if attr is None: return None - if self.entity_type == "uptime": + if self.entity_description.key == "uptime": # reboot happened or entity creation if self._previous_uptime is None or self._previous_uptime > attr: last_boot = utcnow() - timedelta(seconds=attr) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 6baaaaef9f6..d5d0728db77 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -30,9 +30,8 @@ "port": "[%key:common::config_flow::data::port%]" } }, - "reauth": { + "reauth_confirm": { "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%]" @@ -48,7 +47,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "Re-configuration was successful" } }, "options": { diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index e08516ec03a..c5144d64a48 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -19,7 +19,7 @@ from .const import ( DOMAIN, SURVEILLANCE_SWITCH, SYNO_API, - EntityInfo, + SynologyDSMSwitchEntityDescription, ) _LOGGER = logging.getLogger(__name__) @@ -42,12 +42,14 @@ async def async_setup_entry( # initial data fetch coordinator: DataUpdateCoordinator = data[COORDINATOR_SWITCHES] await coordinator.async_refresh() - entities += [ - SynoDSMSurveillanceHomeModeToggle( - api, sensor_type, switch, version, coordinator - ) - for sensor_type, switch in SURVEILLANCE_SWITCH.items() - ] + entities.extend( + [ + SynoDSMSurveillanceHomeModeToggle( + api, version, coordinator, description + ) + for description in SURVEILLANCE_SWITCH + ] + ) async_add_entities(entities, True) @@ -56,28 +58,23 @@ class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, ToggleEntity): """Representation a Synology Surveillance Station Home Mode toggle.""" coordinator: DataUpdateCoordinator[dict[str, dict[str, bool]]] + entity_description: SynologyDSMSwitchEntityDescription def __init__( self, api: SynoApi, - entity_type: str, - entity_info: EntityInfo, version: str, coordinator: DataUpdateCoordinator[dict[str, dict[str, bool]]], + description: SynologyDSMSwitchEntityDescription, ) -> None: """Initialize a Synology Surveillance Station Home Mode.""" - super().__init__( - api, - entity_type, - entity_info, - coordinator, - ) + super().__init__(api, coordinator, description) self._version = version @property def is_on(self) -> bool: """Return the state.""" - return self.coordinator.data["switches"][self.entity_type] + return self.coordinator.data["switches"][self.entity_description.key] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on Home mode.""" diff --git a/homeassistant/components/synology_dsm/translations/ca.json b/homeassistant/components/synology_dsm/translations/ca.json index 2ac5d16b286..d60d560ef1a 100644 --- a/homeassistant/components/synology_dsm/translations/ca.json +++ b/homeassistant/components/synology_dsm/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", + "reconfigure_successful": "Re-configuraci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -38,6 +39,13 @@ "description": "Motiu: {details}", "title": "Reautenticaci\u00f3 de la integraci\u00f3 Synology DSM" }, + "reauth_confirm": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "title": "Reautenticaci\u00f3 de la integraci\u00f3 Synology DSM" + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 86c154e8567..56f835f18f5 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "reconfigure_successful": "Die Neukonfiguration war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -38,6 +39,13 @@ "description": "Grund: {details}", "title": "Synology DSM Integration erneut authentifizieren" }, + "reauth_confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "title": "Synology DSM Integration erneut authentifizieren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/el.json b/homeassistant/components/synology_dsm/translations/el.json index e23b1fe0cf6..460ad657b6c 100644 --- a/homeassistant/components/synology_dsm/translations/el.json +++ b/homeassistant/components/synology_dsm/translations/el.json @@ -1,4 +1,9 @@ { + "config": { + "abort": { + "reconfigure_successful": "\u0397 \u03b5\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 0231f8ddb3c..b86c8a3fe57 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Device is already configured", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -38,6 +39,13 @@ "description": "Reason: {details}", "title": "Synology DSM Reauthenticate Integration" }, + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Synology DSM Reauthenticate Integration" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index 7b86c248110..e143a636fb0 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "El host ya est\u00e1 configurado." + "already_configured": "El host ya est\u00e1 configurado.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "reconfigure_successful": "La reconfiguraci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -30,8 +32,19 @@ "title": "Synology DSM" }, "reauth": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, "description": "Raz\u00f3n: {details}", - "title": "Synology DSM Volver a autenticar la integraci\u00f3n" + "title": "Volver a autenticar la integraci\u00f3n Synology DSM" + }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "title": "Volver a autenticar la integraci\u00f3n Synology DSM" }, "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/et.json b/homeassistant/components/synology_dsm/translations/et.json index eebfd25938b..aea7e31ca72 100644 --- a/homeassistant/components/synology_dsm/translations/et.json +++ b/homeassistant/components/synology_dsm/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "reauth_successful": "Taastuvastamine \u00f5nnestus" + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "reconfigure_successful": "\u00dcmberseadistamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -38,6 +39,13 @@ "description": "P\u00f5hjus: {details}", "title": "Synology DSM: Taastuvasta sidumine" }, + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "title": "Taastuvasta Synology DSM" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index b254fc8e561..2589139b9ec 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "reconfigure_successful": "La reconfiguration a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "missing_data": "Donn\u00e9es manquantes: veuillez r\u00e9essayer plus tard ou utilisez une autre configuration", "otp_failed": "\u00c9chec de l'authentification en deux \u00e9tapes, r\u00e9essayez avec un nouveau code d'acc\u00e8s", - "unknown": "Erreur inconnue: veuillez consulter les journaux pour obtenir plus de d\u00e9tails" + "unknown": "Erreur inattendue" }, "flow_title": "Synology DSM {name} ({host})", "step": { @@ -23,9 +24,9 @@ "data": { "password": "Mot de passe", "port": "Port", - "ssl": "Utilisez SSL/TLS pour vous connecter \u00e0 votre NAS", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifiez le certificat SSL" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Voulez-vous configurer {name} ({host})?", "title": "Synology DSM" @@ -38,14 +39,20 @@ "description": "Raison: {details}", "title": "Synology DSM R\u00e9-authentifier l'int\u00e9gration" }, + "reauth_confirm": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", - "ssl": "Utilisez SSL/TLS pour vous connecter \u00e0 votre NAS", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", - "verify_ssl": "V\u00e9rifiez le certificat SSL" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "title": "Synology DSM" } diff --git a/homeassistant/components/synology_dsm/translations/he.json b/homeassistant/components/synology_dsm/translations/he.json index 4d95d5c2c3c..7adcac9af84 100644 --- a/homeassistant/components/synology_dsm/translations/he.json +++ b/homeassistant/components/synology_dsm/translations/he.json @@ -2,7 +2,8 @@ "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" + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "reconfigure_successful": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7\u05d4" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -28,6 +29,12 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } }, + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/synology_dsm/translations/hu.json b/homeassistant/components/synology_dsm/translations/hu.json index 01f02e6156d..f3f3d4ead4c 100644 --- a/homeassistant/components/synology_dsm/translations/hu.json +++ b/homeassistant/components/synology_dsm/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt", + "reconfigure_successful": "Az \u00fajrakonfigur\u00e1l\u00e1s sikeres" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -27,7 +28,7 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} ({host})?", "title": "Synology DSM" }, "reauth": { @@ -38,9 +39,16 @@ "description": "Indokl\u00e1s: {details}", "title": "Synology DSM Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Synology DSM Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", diff --git a/homeassistant/components/synology_dsm/translations/id.json b/homeassistant/components/synology_dsm/translations/id.json index e614c2578d4..ca322fc518e 100644 --- a/homeassistant/components/synology_dsm/translations/id.json +++ b/homeassistant/components/synology_dsm/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", @@ -10,7 +11,7 @@ "otp_failed": "Autentikasi dua langkah gagal, coba lagi dengan kode sandi baru", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "Synology DSM {name} ({host})", + "flow_title": "{name} ({host})", "step": { "2sa": { "data": { @@ -29,6 +30,18 @@ "description": "Ingin menyiapkan {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "username": "Nama Pengguna" + }, + "title": "Autentikasi Ulang Integrasi Synology DSM" + }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/it.json b/homeassistant/components/synology_dsm/translations/it.json index bb6965255bb..41c535f714f 100644 --- a/homeassistant/components/synology_dsm/translations/it.json +++ b/homeassistant/components/synology_dsm/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "reconfigure_successful": "La riconfigurazione \u00e8 andata a buon fine" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -38,6 +39,13 @@ "description": "Motivo: {details}", "title": "Synology DSM Autenticare nuovamente l'integrazione" }, + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Autenticare nuovamente l'integrazione Synology DSM " + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/nl.json b/homeassistant/components/synology_dsm/translations/nl.json index 6ce9f1b63b9..8740308faf0 100644 --- a/homeassistant/components/synology_dsm/translations/nl.json +++ b/homeassistant/components/synology_dsm/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "reauth_successful": "Herauthenticatie was succesvol" + "reauth_successful": "Herauthenticatie was succesvol", + "reconfigure_successful": "Herconfiguratie was succesvol" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -38,6 +39,13 @@ "description": "Reden: {details}", "title": "Synology DSM Verifieer de integratie opnieuw" }, + "reauth_confirm": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Synology DSM Integratie opnieuw verifi\u00ebren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index d1e2d084f0d..121ffb0c6bf 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reconfigure_successful": "Omkonfigurasjonen var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -38,6 +39,13 @@ "description": "\u00c5rsak: {details}", "title": "Synology DSM Godkjenne integrering p\u00e5 nytt" }, + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "title": "Synology DSM Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/synology_dsm/translations/pl.json b/homeassistant/components/synology_dsm/translations/pl.json index 2979aa2c416..9150333a171 100644 --- a/homeassistant/components/synology_dsm/translations/pl.json +++ b/homeassistant/components/synology_dsm/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "reconfigure_successful": "Ponowna konfiguracja powiod\u0142a si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", diff --git a/homeassistant/components/synology_dsm/translations/ru.json b/homeassistant/components/synology_dsm/translations/ru.json index 4a2963dc5d5..a6b8a8ed201 100644 --- a/homeassistant/components/synology_dsm/translations/ru.json +++ b/homeassistant/components/synology_dsm/translations/ru.json @@ -2,7 +2,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.", - "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." + "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.", + "reconfigure_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \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.", @@ -38,6 +39,13 @@ "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" }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "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/th.json b/homeassistant/components/synology_dsm/translations/th.json new file mode 100644 index 00000000000..301c5267873 --- /dev/null +++ b/homeassistant/components/synology_dsm/translations/th.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reconfigure_successful": "\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e43\u0e2b\u0e21\u0e48\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/tr.json b/homeassistant/components/synology_dsm/translations/tr.json index 681d85d2ef5..f2b93648da0 100644 --- a/homeassistant/components/synology_dsm/translations/tr.json +++ b/homeassistant/components/synology_dsm/translations/tr.json @@ -17,6 +17,12 @@ "verify_ssl": "SSL sertifikalar\u0131n\u0131 do\u011frula" } }, + "reauth_confirm": { + "data": { + "password": "\u015eifre", + "username": "Kullan\u0131c\u0131 ad\u0131" + } + }, "user": { "data": { "host": "Ana Bilgisayar", diff --git a/homeassistant/components/synology_dsm/translations/zh-Hant.json b/homeassistant/components/synology_dsm/translations/zh-Hant.json index c4d466832e7..41eddacdbac 100644 --- a/homeassistant/components/synology_dsm/translations/zh-Hant.json +++ b/homeassistant/components/synology_dsm/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "reconfigure_successful": "\u91cd\u65b0\u8a2d\u5b9a\u6210\u529f" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -38,6 +39,13 @@ "description": "\u8a73\u7d30\u8cc7\u8a0a\uff1a{details}", "title": "Synology DSM \u91cd\u65b0\u8a8d\u8b49\u6574\u5408" }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "Synology DSM \u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index f016cca798d..cba21ac0271 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -85,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.bridge.battery is None or coordinator.bridge.cpu is None or coordinator.bridge.filesystem is None + or coordinator.bridge.graphics is None or coordinator.bridge.information is None or coordinator.bridge.memory is None or coordinator.bridge.network is None @@ -230,17 +231,13 @@ class SystemBridgeEntity(CoordinatorEntity): self, coordinator: SystemBridgeDataUpdateCoordinator, key: str, - name: str, - icon: str | None, - enabled_by_default: bool, + name: str | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) bridge: Bridge = coordinator.data self._key = f"{bridge.information.host}_{key}" self._name = f"{bridge.information.host} {name}" - self._icon = icon - self._enabled_default = enabled_by_default self._hostname = bridge.information.host self._mac = bridge.information.mac self._manufacturer = bridge.system.system.manufacturer @@ -257,16 +254,6 @@ class SystemBridgeEntity(CoordinatorEntity): """Return the name of the entity.""" return self._name - @property - def icon(self) -> str | None: - """Return the mdi icon of the entity.""" - return self._icon - - @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 - class SystemBridgeDeviceEntity(SystemBridgeEntity): """Defines a System Bridge device entity.""" diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index f6b765f8079..a622a3a925a 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -1,11 +1,16 @@ """Support for System Bridge binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from systembridge import Bridge from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_UPDATE, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -15,6 +20,32 @@ from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator +@dataclass +class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing System Bridge binary sensor entities.""" + + value: Callable = round + + +BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( + SystemBridgeBinarySensorEntityDescription( + key="version_available", + name="New Version Available", + device_class=DEVICE_CLASS_UPDATE, + value=lambda bridge: bridge.information.updates.available, + ), +) + +BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( + SystemBridgeBinarySensorEntityDescription( + key="battery_is_charging", + name="Battery Is Charging", + device_class=DEVICE_CLASS_BATTERY_CHARGING, + value=lambda bridge: bridge.information.updates.available, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: @@ -22,49 +53,38 @@ async def async_setup_entry( coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] bridge: Bridge = coordinator.data + entities = [] + for description in BASE_BINARY_SENSOR_TYPES: + entities.append(SystemBridgeBinarySensor(coordinator, description)) + if bridge.battery and bridge.battery.hasBattery: - async_add_entities([SystemBridgeBatteryIsChargingBinarySensor(coordinator)]) + for description in BATTERY_BINARY_SENSOR_TYPES: + entities.append(SystemBridgeBinarySensor(coordinator, description)) + + async_add_entities(entities) class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): - """Defines a System Bridge binary sensor.""" + """Define a System Bridge binary sensor.""" + + coordinator: SystemBridgeDataUpdateCoordinator + entity_description: SystemBridgeBinarySensorEntityDescription def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, - key: str, - name: str, - icon: str | None, - device_class: str | None, - enabled_by_default: bool, + description: SystemBridgeBinarySensorEntityDescription, ) -> None: - """Initialize System Bridge binary sensor.""" - self._device_class = device_class - - super().__init__(coordinator, key, name, icon, enabled_by_default) - - @property - def device_class(self) -> str | None: - """Return the class of this binary sensor.""" - return self._device_class - - -class SystemBridgeBatteryIsChargingBinarySensor(SystemBridgeBinarySensor): - """Defines a Battery is charging binary sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge binary sensor.""" + """Initialize.""" super().__init__( coordinator, - "battery_is_charging", - "Battery Is Charging", - None, - DEVICE_CLASS_BATTERY_CHARGING, - True, + description.key, + description.name, ) + self.entity_description = description @property def is_on(self) -> bool: - """Return if the state is on.""" + """Return the boolean state of the binary sensor.""" bridge: Bridge = self.coordinator.data - return bridge.battery.isCharging + return self.entity_description.value(bridge) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index d34e1019a0b..fb0b63c715a 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging -from typing import Callable from systembridge import Bridge from systembridge.exceptions import ( @@ -66,6 +66,7 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): "battery", "cpu", "filesystem", + "graphics", "memory", "network", "os", diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 2f1ec0111cf..73d1d03618f 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,7 +3,7 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==2.0.6"], + "requirements": ["systembridge==2.1.0"], "codeowners": ["@timmo001"], "zeroconf": ["_system-bridge._udp.local."], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index acfcc54f05c..3e8d2aa7ff0 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -1,40 +1,196 @@ """Support for System Bridge sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Final, cast from systembridge import Bridge -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_GIGABYTES, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, FREQUENCY_GIGAHERTZ, + FREQUENCY_MEGAHERTZ, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType from . import SystemBridgeDeviceEntity from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator -ATTR_AVAILABLE = "available" -ATTR_FILESYSTEM = "filesystem" -ATTR_LOAD_AVERAGE = "load_average" -ATTR_LOAD_IDLE = "load_idle" -ATTR_LOAD_SYSTEM = "load_system" -ATTR_LOAD_USER = "load_user" -ATTR_MOUNT = "mount" -ATTR_SIZE = "size" -ATTR_TYPE = "type" -ATTR_USED = "used" +ATTR_AVAILABLE: Final = "available" +ATTR_FILESYSTEM: Final = "filesystem" +ATTR_MOUNT: Final = "mount" +ATTR_SIZE: Final = "size" +ATTR_TYPE: Final = "type" +ATTR_USED: Final = "used" + + +@dataclass +class SystemBridgeSensorEntityDescription(SensorEntityDescription): + """Class describing System Bridge sensor entities.""" + + value: Callable = round + + +BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( + SystemBridgeSensorEntityDescription( + key="bios_version", + name="BIOS Version", + entity_registry_enabled_default=False, + icon="mdi:chip", + value=lambda bridge: bridge.system.bios.version, + ), + SystemBridgeSensorEntityDescription( + key="cpu_speed", + name="CPU Speed", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=FREQUENCY_GIGAHERTZ, + icon="mdi:speedometer", + value=lambda bridge: bridge.cpu.currentSpeed.avg, + ), + SystemBridgeSensorEntityDescription( + key="cpu_temperature", + name="CPU Temperature", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value=lambda bridge: bridge.cpu.temperature.main, + ), + SystemBridgeSensorEntityDescription( + key="cpu_voltage", + name="CPU Voltage", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value=lambda bridge: bridge.cpu.cpu.voltage, + ), + SystemBridgeSensorEntityDescription( + key="kernel", + name="Kernel", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:devices", + value=lambda bridge: bridge.os.kernel, + ), + SystemBridgeSensorEntityDescription( + key="memory_free", + name="Memory Free", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge: round(bridge.memory.free / 1000 ** 3, 2), + ), + SystemBridgeSensorEntityDescription( + key="memory_used_percentage", + name="Memory Used %", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + value=lambda bridge: round((bridge.memory.used / bridge.memory.total) * 100, 2), + ), + SystemBridgeSensorEntityDescription( + key="memory_used", + name="Memory Used", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge: round(bridge.memory.used / 1000 ** 3, 2), + ), + SystemBridgeSensorEntityDescription( + key="os", + name="Operating System", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:devices", + value=lambda bridge: f"{bridge.os.distro} {bridge.os.release}", + ), + SystemBridgeSensorEntityDescription( + key="processes_load", + name="Load", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoad, 2), + ), + SystemBridgeSensorEntityDescription( + key="processes_load_idle", + name="Idle Load", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoadIdle, 2), + ), + SystemBridgeSensorEntityDescription( + key="processes_load_system", + name="System Load", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoadSystem, 2), + ), + SystemBridgeSensorEntityDescription( + key="processes_load_user", + name="User Load", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge: round(bridge.processes.load.currentLoadUser, 2), + ), + SystemBridgeSensorEntityDescription( + key="version", + name="Version", + icon="mdi:counter", + value=lambda bridge: bridge.information.version, + ), + SystemBridgeSensorEntityDescription( + key="version_latest", + name="Latest Version", + icon="mdi:counter", + value=lambda bridge: bridge.information.updates.version.new, + ), +) + +BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( + SystemBridgeSensorEntityDescription( + key="battery", + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value=lambda bridge: bridge.battery.percent, + ), + SystemBridgeSensorEntityDescription( + key="battery_time_remaining", + name="Battery Time Remaining", + device_class=DEVICE_CLASS_TIMESTAMP, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda bridge: str( + datetime.now() + timedelta(minutes=bridge.battery.timeRemaining) + ), + ), +) async def async_setup_entry( @@ -43,395 +199,260 @@ async def async_setup_entry( """Set up System Bridge sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [ - SystemBridgeCpuSpeedSensor(coordinator), - SystemBridgeCpuTemperatureSensor(coordinator), - SystemBridgeCpuVoltageSensor(coordinator), - *( - SystemBridgeFilesystemSensor(coordinator, key) - for key, _ in coordinator.data.filesystem.fsSize.items() - ), - SystemBridgeMemoryFreeSensor(coordinator), - SystemBridgeMemoryUsedSensor(coordinator), - SystemBridgeMemoryUsedPercentageSensor(coordinator), - SystemBridgeKernelSensor(coordinator), - SystemBridgeOsSensor(coordinator), - SystemBridgeProcessesLoadSensor(coordinator), - SystemBridgeBiosVersionSensor(coordinator), - ] + entities = [] + for description in BASE_SENSOR_TYPES: + entities.append(SystemBridgeSensor(coordinator, description)) + + for key, _ in coordinator.data.filesystem.fsSize.items(): + uid = key.replace(":", "") + entities.append( + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"filesystem_{uid}", + name=f"{key} Space Used", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + value=lambda bridge, i=key: round( + bridge.filesystem.fsSize[i]["use"], 2 + ), + ), + ) + ) if coordinator.data.battery.hasBattery: - entities.append(SystemBridgeBatterySensor(coordinator)) - entities.append(SystemBridgeBatteryTimeRemainingSensor(coordinator)) + for description in BATTERY_SENSOR_TYPES: + entities.append(SystemBridgeSensor(coordinator, description)) + + for index, _ in enumerate(coordinator.data.graphics.controllers): + if coordinator.data.graphics.controllers[index].name is not None: + # Remove vendor from name + name = ( + coordinator.data.graphics.controllers[index] + .name.replace(coordinator.data.graphics.controllers[index].vendor, "") + .strip() + ) + entities = [ + *entities, + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_core_clock_speed", + name=f"{name} Clock Speed", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].clockCore, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_clock_speed", + name=f"{name} Memory Clock Speed", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].clockMemory, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_free", + name=f"{name} Memory Free", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge, i=index: round( + bridge.graphics.controllers[i].memoryFree / 10 ** 3, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used_percentage", + name=f"{name} Memory Used %", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + value=lambda bridge, i=index: round( + ( + bridge.graphics.controllers[i].memoryUsed + / bridge.graphics.controllers[i].memoryTotal + ) + * 100, + 2, + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used", + name=f"{name} Memory Used", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda bridge, i=index: round( + bridge.graphics.controllers[i].memoryUsed / 10 ** 3, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_fan_speed", + name=f"{name} Fan Speed", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fan", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].fanSpeed, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_power_usage", + name=f"{name} Power Usage", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].powerDraw, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_temperature", + name=f"{name} Temperature", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].temperatureGpu, + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_usage_percentage", + name=f"{name} Usage %", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge, i=index: bridge.graphics.controllers[ + i + ].utilizationGpu, + ), + ), + ] + + for index, _ in enumerate(coordinator.data.processes.load.cpus): + entities = [ + *entities, + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}", + name=f"Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge, index=index: round( + bridge.processes.load.cpus[index].load, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}_idle", + name=f"Idle Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge, index=index: round( + bridge.processes.load.cpus[index].loadIdle, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}_system", + name=f"System Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge, index=index: round( + bridge.processes.load.cpus[index].loadSystem, 2 + ), + ), + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"processes_load_cpu_{index}_user", + name=f"User Load CPU {index}", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda bridge, index=index: round( + bridge.processes.load.cpus[index].loadUser, 2 + ), + ), + ), + ] async_add_entities(entities) class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): - """Defines a System Bridge sensor.""" + """Define a System Bridge sensor.""" + + coordinator: SystemBridgeDataUpdateCoordinator + entity_description: SystemBridgeSensorEntityDescription def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, - key: str, - name: str, - icon: str | None, - device_class: str | None, - unit_of_measurement: str | None, - enabled_by_default: bool, + description: SystemBridgeSensorEntityDescription, ) -> None: - """Initialize System Bridge sensor.""" - self._device_class = device_class - self._unit_of_measurement = unit_of_measurement - - super().__init__(coordinator, key, name, icon, enabled_by_default) - - @property - def device_class(self) -> str | None: - """Return the class of this sensor.""" - return self._device_class - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class SystemBridgeBatterySensor(SystemBridgeSensor): - """Defines a Battery sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" + """Initialize.""" super().__init__( coordinator, - "battery", - "Battery", - None, - DEVICE_CLASS_BATTERY, - PERCENTAGE, - True, + description.key, + description.name, ) + self.entity_description = description @property - def native_value(self) -> float: - """Return the state of the sensor.""" + def native_value(self) -> StateType: + """Return the state.""" bridge: Bridge = self.coordinator.data - return bridge.battery.percent - - -class SystemBridgeBatteryTimeRemainingSensor(SystemBridgeSensor): - """Defines the Battery Time Remaining sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "battery_time_remaining", - "Battery Time Remaining", - None, - DEVICE_CLASS_TIMESTAMP, - None, - True, - ) - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - if bridge.battery.timeRemaining is None: + try: + return cast(StateType, self.entity_description.value(bridge)) + except TypeError: return None - return str(datetime.now() + timedelta(minutes=bridge.battery.timeRemaining)) - - -class SystemBridgeCpuSpeedSensor(SystemBridgeSensor): - """Defines a CPU speed sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "cpu_speed", - "CPU Speed", - "mdi:speedometer", - None, - FREQUENCY_GIGAHERTZ, - True, - ) - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.cpu.currentSpeed.avg - - -class SystemBridgeCpuTemperatureSensor(SystemBridgeSensor): - """Defines a CPU temperature sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "cpu_temperature", - "CPU Temperature", - None, - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, - False, - ) - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.cpu.temperature.main - - -class SystemBridgeCpuVoltageSensor(SystemBridgeSensor): - """Defines a CPU voltage sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "cpu_voltage", - "CPU Voltage", - None, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_POTENTIAL_VOLT, - False, - ) - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.cpu.cpu.voltage - - -class SystemBridgeFilesystemSensor(SystemBridgeSensor): - """Defines a filesystem sensor.""" - - def __init__( - self, coordinator: SystemBridgeDataUpdateCoordinator, key: str - ) -> None: - """Initialize System Bridge sensor.""" - uid_key = key.replace(":", "") - super().__init__( - coordinator, - f"filesystem_{uid_key}", - f"{key} Space Used", - "mdi:harddisk", - None, - PERCENTAGE, - True, - ) - self._fs_key = key - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.filesystem.fsSize[self._fs_key]["use"], 2) - if bridge.filesystem.fsSize[self._fs_key]["use"] is not None - else None - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the entity.""" - bridge: Bridge = self.coordinator.data - return { - 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"], - } - - -class SystemBridgeMemoryFreeSensor(SystemBridgeSensor): - """Defines a memory free sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "memory_free", - "Memory Free", - "mdi:memory", - None, - DATA_GIGABYTES, - True, - ) - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.memory.free / 1000 ** 3, 2) - if bridge.memory.free is not None - else None - ) - - -class SystemBridgeMemoryUsedSensor(SystemBridgeSensor): - """Defines a memory used sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "memory_used", - "Memory Used", - "mdi:memory", - None, - DATA_GIGABYTES, - False, - ) - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.memory.used / 1000 ** 3, 2) - if bridge.memory.used is not None - else None - ) - - -class SystemBridgeMemoryUsedPercentageSensor(SystemBridgeSensor): - """Defines a memory used percentage sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "memory_used_percentage", - "Memory Used %", - "mdi:memory", - None, - PERCENTAGE, - True, - ) - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round((bridge.memory.used / bridge.memory.total) * 100, 2) - if bridge.memory.used is not None and bridge.memory.total is not None - else None - ) - - -class SystemBridgeKernelSensor(SystemBridgeSensor): - """Defines a kernel sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "kernel", - "Kernel", - "mdi:devices", - None, - None, - True, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.os.kernel - - -class SystemBridgeOsSensor(SystemBridgeSensor): - """Defines an OS sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "os", - "Operating System", - "mdi:devices", - None, - None, - True, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return f"{bridge.os.distro} {bridge.os.release}" - - -class SystemBridgeProcessesLoadSensor(SystemBridgeSensor): - """Defines a Processes Load sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "processes_load", - "Load", - "mdi:percent", - None, - PERCENTAGE, - True, - ) - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return ( - round(bridge.processes.load.currentLoad, 2) - if bridge.processes.load.currentLoad is not None - else None - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the entity.""" - bridge: Bridge = self.coordinator.data - attrs = {} - if bridge.processes.load.avgLoad is not None: - attrs[ATTR_LOAD_AVERAGE] = round(bridge.processes.load.avgLoad, 2) - if bridge.processes.load.currentLoadUser is not None: - attrs[ATTR_LOAD_USER] = round(bridge.processes.load.currentLoadUser, 2) - if bridge.processes.load.currentLoadSystem is not None: - attrs[ATTR_LOAD_SYSTEM] = round(bridge.processes.load.currentLoadSystem, 2) - if bridge.processes.load.currentLoadIdle is not None: - attrs[ATTR_LOAD_IDLE] = round(bridge.processes.load.currentLoadIdle, 2) - return attrs - - -class SystemBridgeBiosVersionSensor(SystemBridgeSensor): - """Defines a bios version sensor.""" - - def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: - """Initialize System Bridge sensor.""" - super().__init__( - coordinator, - "bios_version", - "BIOS Version", - "mdi:chip", - None, - None, - False, - ) - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - bridge: Bridge = self.coordinator.data - return bridge.system.bios.version diff --git a/homeassistant/components/system_bridge/translations/fr.json b/homeassistant/components/system_bridge/translations/fr.json index a21fab81777..99b38c44ad8 100644 --- a/homeassistant/components/system_bridge/translations/fr.json +++ b/homeassistant/components/system_bridge/translations/fr.json @@ -2,25 +2,25 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "flow_title": "Pont syst\u00e8me: {name}", "step": { "authenticate": { "data": { - "api_key": "Clef d'API" + "api_key": "Cl\u00e9 d'API" }, "description": "Veuillez saisir la cl\u00e9 API que vous avez d\u00e9finie dans votre configuration pour {name} ." }, "user": { "data": { - "api_key": "Clef d'API", + "api_key": "Cl\u00e9 d'API", "host": "H\u00f4te", "port": "Port" }, diff --git a/homeassistant/components/system_bridge/translations/hu.json b/homeassistant/components/system_bridge/translations/hu.json index 50643ca5e95..31082202419 100644 --- a/homeassistant/components/system_bridge/translations/hu.json +++ b/homeassistant/components/system_bridge/translations/hu.json @@ -21,7 +21,7 @@ "user": { "data": { "api_key": "API kulcs", - "host": "Gazdag\u00e9p", + "host": "C\u00edm", "port": "Port" }, "description": "K\u00e9rj\u00fck, adja meg kapcsolati adatait." diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 651961c72ac..2683f6a2f3a 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -2,11 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import dataclasses from datetime import datetime import logging -from typing import Callable import aiohttp import async_timeout diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 687e9e8e521..9360f2a3168 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -14,7 +14,13 @@ from typing import Any, cast import psutil import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_RESOURCES, CONF_SCAN_INTERVAL, @@ -60,62 +66,180 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" -# Schema: [name, unit of measurement, icon, device class, flag if mandatory arg] -SENSOR_TYPES: dict[str, tuple[str, str | None, str | None, str | None, bool]] = { - "disk_free": ("Disk free", DATA_GIBIBYTES, "mdi:harddisk", None, False), - "disk_use": ("Disk use", DATA_GIBIBYTES, "mdi:harddisk", None, False), - "disk_use_percent": ( - "Disk use (percent)", - PERCENTAGE, - "mdi:harddisk", - None, - False, + +@dataclass +class SysMonitorSensorEntityDescription(SensorEntityDescription): + """Description for System Monitor sensor entities.""" + + mandatory_arg: bool = False + + +SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { + "disk_free": SysMonitorSensorEntityDescription( + key="disk_free", + name="Disk free", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "ipv4_address": ("IPv4 address", "", "mdi:server-network", None, True), - "ipv6_address": ("IPv6 address", "", "mdi:server-network", None, True), - "last_boot": ("Last boot", None, None, DEVICE_CLASS_TIMESTAMP, False), - "load_15m": ("Load (15m)", " ", CPU_ICON, None, False), - "load_1m": ("Load (1m)", " ", CPU_ICON, None, False), - "load_5m": ("Load (5m)", " ", CPU_ICON, None, False), - "memory_free": ("Memory free", DATA_MEBIBYTES, "mdi:memory", None, False), - "memory_use": ("Memory use", DATA_MEBIBYTES, "mdi:memory", None, False), - "memory_use_percent": ( - "Memory use (percent)", - PERCENTAGE, - "mdi:memory", - None, - False, + "disk_use": SysMonitorSensorEntityDescription( + key="disk_use", + name="Disk use", + native_unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "network_in": ("Network in", DATA_MEBIBYTES, "mdi:server-network", None, True), - "network_out": ("Network out", DATA_MEBIBYTES, "mdi:server-network", None, True), - "packets_in": ("Packets in", " ", "mdi:server-network", None, True), - "packets_out": ("Packets out", " ", "mdi:server-network", None, True), - "throughput_network_in": ( - "Network throughput in", - DATA_RATE_MEGABYTES_PER_SECOND, - "mdi:server-network", - None, - True, + "disk_use_percent": SysMonitorSensorEntityDescription( + key="disk_use_percent", + name="Disk use (percent)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "throughput_network_out": ( - "Network throughput out", - DATA_RATE_MEGABYTES_PER_SECOND, - "mdi:server-network", - None, - True, + "ipv4_address": SysMonitorSensorEntityDescription( + key="ipv4_address", + name="IPv4 address", + icon="mdi:server-network", + mandatory_arg=True, ), - "process": ("Process", " ", CPU_ICON, None, True), - "processor_use": ("Processor use (percent)", PERCENTAGE, CPU_ICON, None, False), - "processor_temperature": ( - "Processor temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - False, + "ipv6_address": SysMonitorSensorEntityDescription( + key="ipv6_address", + name="IPv6 address", + icon="mdi:server-network", + mandatory_arg=True, + ), + "last_boot": SysMonitorSensorEntityDescription( + key="last_boot", + name="Last boot", + device_class=DEVICE_CLASS_TIMESTAMP, + ), + "load_15m": SysMonitorSensorEntityDescription( + key="load_15m", + name="Load (15m)", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "load_1m": SysMonitorSensorEntityDescription( + key="load_1m", + name="Load (1m)", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "load_5m": SysMonitorSensorEntityDescription( + key="load_5m", + name="Load (5m)", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "memory_free": SysMonitorSensorEntityDescription( + key="memory_free", + name="Memory free", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + state_class=STATE_CLASS_TOTAL, + ), + "memory_use": SysMonitorSensorEntityDescription( + key="memory_use", + name="Memory use", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + state_class=STATE_CLASS_TOTAL, + ), + "memory_use_percent": SysMonitorSensorEntityDescription( + key="memory_use_percent", + name="Memory use (percent)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + state_class=STATE_CLASS_TOTAL, + ), + "network_in": SysMonitorSensorEntityDescription( + key="network_in", + name="Network in", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "network_out": SysMonitorSensorEntityDescription( + key="network_out", + name="Network out", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "packets_in": SysMonitorSensorEntityDescription( + key="packets_in", + name="Packets in", + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "packets_out": SysMonitorSensorEntityDescription( + key="packets_out", + name="Packets out", + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL_INCREASING, + mandatory_arg=True, + ), + "throughput_network_in": SysMonitorSensorEntityDescription( + key="throughput_network_in", + name="Network throughput in", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL, + mandatory_arg=True, + ), + "throughput_network_out": SysMonitorSensorEntityDescription( + key="throughput_network_out", + name="Network throughput out", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + icon="mdi:server-network", + state_class=STATE_CLASS_TOTAL, + mandatory_arg=True, + ), + "process": SysMonitorSensorEntityDescription( + key="process", + name="Process", + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + mandatory_arg=True, + ), + "processor_use": SysMonitorSensorEntityDescription( + key="processor_use", + name="Processor use", + native_unit_of_measurement=PERCENTAGE, + icon=CPU_ICON, + state_class=STATE_CLASS_TOTAL, + ), + "processor_temperature": SysMonitorSensorEntityDescription( + key="processor_temperature", + name="Processor temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_TOTAL, + ), + "swap_free": SysMonitorSensorEntityDescription( + key="swap_free", + name="Swap free", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, + ), + "swap_use": SysMonitorSensorEntityDescription( + key="swap_use", + name="Swap use", + native_unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, + ), + "swap_use_percent": SysMonitorSensorEntityDescription( + key="swap_use_percent", + name="Swap use (percent)", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + state_class=STATE_CLASS_TOTAL, ), - "swap_free": ("Swap free", DATA_MEBIBYTES, "mdi:harddisk", None, False), - "swap_use": ("Swap use", DATA_MEBIBYTES, "mdi:harddisk", None, False), - "swap_use_percent": ("Swap use (percent)", PERCENTAGE, "mdi:harddisk", None, False), } @@ -125,7 +249,7 @@ def check_required_arg(value: Any) -> Any: sensor_type = sensor[CONF_TYPE] sensor_arg = sensor.get(CONF_ARG) - if sensor_arg is None and SENSOR_TYPES[sensor_type][SENSOR_TYPE_MANDATORY_ARG]: + if sensor_arg is None and SENSOR_TYPES[sensor_type].mandatory_arg: raise vol.RequiredFieldInvalid( f"Mandatory 'arg' is missing for sensor type '{sensor_type}'." ) @@ -230,7 +354,9 @@ async def async_setup_platform( sensor_registry[(type_, argument)] = SensorData( argument, None, None, None, None ) - entities.append(SystemMonitorSensor(sensor_registry, type_, argument)) + entities.append( + SystemMonitorSensor(sensor_registry, SENSOR_TYPES[type_], argument) + ) scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) @@ -297,68 +423,35 @@ async def async_setup_sensor_registry_updates( class SystemMonitorSensor(SensorEntity): """Implementation of a system monitor sensor.""" + should_poll = False + def __init__( self, sensor_registry: dict[tuple[str, str], SensorData], - sensor_type: str, + sensor_description: SysMonitorSensorEntityDescription, argument: str = "", ) -> None: """Initialize the sensor.""" - self._type: str = sensor_type - self._name: str = f"{self.sensor_type[SENSOR_TYPE_NAME]} {argument}".rstrip() - self._unique_id: str = slugify(f"{sensor_type}_{argument}") + self.entity_description = sensor_description + self._attr_name: str = f"{sensor_description.name} {argument}".rstrip() + self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") self._sensor_registry = sensor_registry self._argument: str = argument - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self) -> str: - """Return the unique ID.""" - return self._unique_id - - @property - def device_class(self) -> str | None: - """Return the class of this sensor.""" - return self.sensor_type[SENSOR_TYPE_DEVICE_CLASS] # type: ignore[no-any-return] - - @property - def icon(self) -> str | None: - """Icon to use in the frontend, if any.""" - return self.sensor_type[SENSOR_TYPE_ICON] # type: ignore[no-any-return] - @property def native_value(self) -> str | None: """Return the state of the device.""" return self.data.state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - return self.sensor_type[SENSOR_TYPE_UOM] # type: ignore[no-any-return] - @property def available(self) -> bool: """Return True if entity is available.""" return self.data.last_exception is None - @property - def should_poll(self) -> bool: - """Entity does not poll.""" - return False - - @property - def sensor_type(self) -> list: - """Return sensor type data for the sensor.""" - return SENSOR_TYPES[self._type] # type: ignore - @property def data(self) -> SensorData: """Return registry entry for the data.""" - return self._sensor_registry[(self._type, self._argument)] + return self._sensor_registry[(self.entity_description.key, self._argument)] async def async_added_to_hass(self) -> None: """When entity is added to hass.""" diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 3cb2abe90f6..7a1965e31fb 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging from PyTado.interface import Tado +from PyTado.zone import TadoZone from requests import RequestException import requests.exceptions @@ -155,52 +156,96 @@ class TadoConnector: @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" - for device in self.devices: - self.update_sensor("device", device["shortSerialNo"]) - for zone in self.zones: - self.update_sensor("zone", zone["id"]) + self.update_devices() + self.update_zones() self.data["weather"] = self.tado.getWeather() dispatcher_send( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "weather", "data"), ) - def update_sensor(self, sensor_type, sensor): - """Update the internal data from Tado.""" - _LOGGER.debug("Updating %s %s", sensor_type, sensor) - try: - if sensor_type == "device": - data = self.tado.getDeviceInfo(sensor) + def update_devices(self): + """Update the device data from Tado.""" + devices = self.tado.getDevices() + for device in devices: + device_short_serial_no = device["shortSerialNo"] + _LOGGER.debug("Updating device %s", device_short_serial_no) + try: if ( INSIDE_TEMPERATURE_MEASUREMENT - in data["characteristics"]["capabilities"] + in device["characteristics"]["capabilities"] ): - data[TEMP_OFFSET] = self.tado.getDeviceInfo(sensor, TEMP_OFFSET) - elif sensor_type == "zone": - data = self.tado.getZoneState(sensor) - else: - _LOGGER.debug("Unknown sensor: %s", sensor_type) + device[TEMP_OFFSET] = self.tado.getDeviceInfo( + device_short_serial_no, TEMP_OFFSET + ) + except RuntimeError: + _LOGGER.error( + "Unable to connect to Tado while updating device %s", + device_short_serial_no, + ) return - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating %s %s", - sensor_type, - sensor, + + self.data["device"][device_short_serial_no] = device + + _LOGGER.debug( + "Dispatching update to %s device %s: %s", + self.home_id, + device_short_serial_no, + device, ) + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format( + self.home_id, "device", device_short_serial_no + ), + ) + + def update_zones(self): + """Update the zone data from Tado.""" + try: + zone_states = self.tado.getZoneStates()["zoneStates"] + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating zones") return - self.data[sensor_type][sensor] = data + for zone in self.zones: + zone_id = zone["id"] + _LOGGER.debug("Updating zone %s", zone_id) + zone_state = TadoZone(zone_states[str(zone_id)], zone_id) + + self.data["zone"][zone_id] = zone_state + + _LOGGER.debug( + "Dispatching update to %s zone %s: %s", + self.home_id, + zone_id, + zone_state, + ) + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone["id"]), + ) + + def update_zone(self, zone_id): + """Update the internal data from Tado.""" + _LOGGER.debug("Updating zone %s", zone_id) + try: + data = self.tado.getZoneState(zone_id) + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) + return + + self.data["zone"][zone_id] = data _LOGGER.debug( - "Dispatching update to %s %s %s: %s", + "Dispatching update to %s zone %s: %s", self.home_id, - sensor_type, - sensor, + zone_id, data, ) dispatcher_send( self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, sensor_type, sensor), + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), ) def get_capabilities(self, zone_id): @@ -210,7 +255,7 @@ class TadoConnector: def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" self.tado.resetZoneOverlay(zone_id) - self.update_sensor("zone", zone_id) + self.update_zone(zone_id) def set_presence( self, @@ -262,7 +307,7 @@ class TadoConnector: except RequestException as exc: _LOGGER.error("Could not set zone overlay: %s", exc) - self.update_sensor("zone", zone_id) + self.update_zone(zone_id) def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): """Set a zone to off.""" @@ -273,7 +318,7 @@ class TadoConnector: except RequestException as exc: _LOGGER.error("Could not set zone overlay: %s", exc) - self.update_sensor("zone", zone_id) + self.update_zone(zone_id) def set_temperature_offset(self, device_id, offset): """Set temperature offset of device.""" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 8cf0ed260e8..a77974ab803 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -2,7 +2,7 @@ "domain": "tado", "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", - "requirements": ["python-tado==0.10.0"], + "requirements": ["python-tado==0.12.0"], "codeowners": ["@michaelarnauts", "@noltari"], "config_flow": true, "homekit": { diff --git a/homeassistant/components/tado/translations/fr.json b/homeassistant/components/tado/translations/fr.json index 0ebbe4054a1..d862a27f392 100644 --- a/homeassistant/components/tado/translations/fr.json +++ b/homeassistant/components/tado/translations/fr.json @@ -4,8 +4,8 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", - "invalid_auth": "Authentification non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", "no_homes": "Il n\u2019y a pas de maisons li\u00e9es \u00e0 ce compte tado.", "unknown": "Erreur inattendue" }, diff --git a/homeassistant/components/tag/trigger.py b/homeassistant/components/tag/trigger.py index ba90f0a9396..b844ee260a2 100644 --- a/homeassistant/components/tag/trigger.py +++ b/homeassistant/components/tag/trigger.py @@ -1,7 +1,10 @@ """Support for tag triggers.""" import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv @@ -22,10 +25,10 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Listen for tag_scanned events based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] tag_ids = set(config[TAG_ID]) device_ids = set(config[DEVICE_ID]) if DEVICE_ID in config else None diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 93794ce0c50..2ffff492b92 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -111,11 +111,9 @@ class TankUtilitySensor(SensorEntity): try: data = tank_monitor.get_device_data(self._token, self.device) except requests.exceptions.HTTPError as http_error: - if ( - http_error.response.status_code - == requests.codes.unauthorized # pylint: disable=no-member - or http_error.response.status_code - == requests.codes.bad_request # pylint: disable=no-member + if http_error.response.status_code in ( + requests.codes.unauthorized, # pylint: disable=no-member + requests.codes.bad_request, # pylint: disable=no-member ): _LOGGER.info("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 1ccee0bf7d3..a0f4dfff5ac 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -1,8 +1,9 @@ """Support for Tasmota binary sensors.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime -from typing import Any, Callable +from typing import Any from hatasmota import switch as tasmota_switch from hatasmota.entity import TasmotaEntity as HATasmotaEntity diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index b3be1fbd2cc..61efbb76e23 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -1,15 +1,19 @@ """Provides device automations for Tasmota.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import attr from hatasmota.models import DiscoveryHashType from hatasmota.trigger import TasmotaTrigger, TasmotaTriggerConfig import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) 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 @@ -49,7 +53,7 @@ class TriggerInstance: """Attached trigger settings.""" action: AutomationActionType = attr.ib() - automation_info: dict = attr.ib() + automation_info: AutomationTriggerInfo = attr.ib() trigger: Trigger = attr.ib() remove: CALLBACK_TYPE | None = attr.ib(default=None) @@ -93,7 +97,7 @@ class Trigger: trigger_instances: list[TriggerInstance] = attr.ib(factory=list) async def add_trigger( - self, action: AutomationActionType, automation_info: dict + self, action: AutomationActionType, automation_info: AutomationTriggerInfo ) -> Callable[[], None]: """Add Tasmota trigger.""" instance = TriggerInstance(action, automation_info, self) @@ -289,7 +293,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: Callable, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a device trigger.""" if DEVICE_TRIGGERS not in hass.data: diff --git a/homeassistant/components/tasmota/translations/he.json b/homeassistant/components/tasmota/translations/he.json index eefc72310d4..a2a5db62b37 100644 --- a/homeassistant/components/tasmota/translations/he.json +++ b/homeassistant/components/tasmota/translations/he.json @@ -11,7 +11,7 @@ "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.", + "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea Tasmota.", "title": "Tasmota" }, "confirm": { diff --git a/homeassistant/components/tasmota/translations/hu.json b/homeassistant/components/tasmota/translations/hu.json index 72a26925bc9..3a6c32dbb42 100644 --- a/homeassistant/components/tasmota/translations/hu.json +++ b/homeassistant/components/tasmota/translations/hu.json @@ -11,11 +11,11 @@ "data": { "discovery_prefix": "Felder\u00edt\u00e9si t\u00e9ma el\u0151tagja" }, - "description": "Add meg a Tasmota konfigur\u00e1ci\u00f3t.", + "description": "Adja meg a Tasmota konfigur\u00e1ci\u00f3t.", "title": "Tasmota" }, "confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Tasmota-t?" + "description": "Szeretn\u00e9 b\u00e1ll\u00edtani a Tasmota-t?" } } } diff --git a/homeassistant/components/tautulli/const.py b/homeassistant/components/tautulli/const.py new file mode 100644 index 00000000000..a7427e401ba --- /dev/null +++ b/homeassistant/components/tautulli/const.py @@ -0,0 +1,5 @@ +"""Constants for the Tautulli integration.""" +from logging import Logger, getLogger + +DOMAIN = "tautulli" +LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/tautulli/coordinator.py b/homeassistant/components/tautulli/coordinator.py new file mode 100644 index 00000000000..6ca2ed0d7d6 --- /dev/null +++ b/homeassistant/components/tautulli/coordinator.py @@ -0,0 +1,52 @@ +"""Data update coordinator for the Tautulli integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from pytautulli import ( + PyTautulli, + PyTautulliApiActivity, + PyTautulliApiHomeStats, + PyTautulliApiUser, + PyTautulliException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class TautulliDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Tautulli integration.""" + + def __init__( + self, + hass: HomeAssistant, + api_client: PyTautulli, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.api_client = api_client + self.activity: PyTautulliApiActivity | None = None + self.home_stats: list[PyTautulliApiHomeStats] | None = None + self.users: list[PyTautulliApiUser] | None = None + + async def _async_update_data(self) -> None: + """Get the latest data from Tautulli.""" + try: + [self.activity, self.home_stats, self.users] = await asyncio.gather( + *[ + self.api_client.async_get_activity(), + self.api_client.async_get_home_stats(), + self.api_client.async_get_users(), + ] + ) + except PyTautulliException as exception: + raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 16b58b206aa..814f6c9da50 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,5 +1,7 @@ """A platform which allows you to get information from Tautulli.""" -from datetime import timedelta +from __future__ import annotations + +from typing import Any from pytautulli import PyTautulli import voluptuous as vol @@ -15,10 +17,14 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TautulliDataUpdateCoordinator CONF_MONITORED_USERS = "monitored_users" @@ -28,8 +34,6 @@ DEFAULT_PATH = "" DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True -TIME_BETWEEN_UPDATES = timedelta(seconds=10) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -45,146 +49,134 @@ 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: """Create the Tautulli sensor.""" - name = config.get(CONF_NAME) + name = config[CONF_NAME] host = config[CONF_HOST] - port = config.get(CONF_PORT) - path = config.get(CONF_PATH) + port = config[CONF_PORT] + path = config[CONF_PATH] api_key = config[CONF_API_KEY] - monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - user = config.get(CONF_MONITORED_USERS) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS, []) + users = config.get(CONF_MONITORED_USERS, []) use_ssl = config[CONF_SSL] - verify_ssl = config.get(CONF_VERIFY_SSL) + verify_ssl = config[CONF_VERIFY_SSL] - session = async_get_clientsession(hass, verify_ssl) - tautulli = TautulliData( - PyTautulli( - api_token=api_key, - hostname=host, - session=session, - verify_ssl=verify_ssl, - port=port, - ssl=use_ssl, - base_api_path=path, - ) + session = async_get_clientsession(hass=hass, verify_ssl=verify_ssl) + + api_client = PyTautulli( + api_token=api_key, + hostname=host, + session=session, + verify_ssl=verify_ssl, + port=port, + ssl=use_ssl, + base_api_path=path, ) - await tautulli.async_update() - if not tautulli.activity or not tautulli.home_stats or not tautulli.users: - raise PlatformNotReady + coordinator = TautulliDataUpdateCoordinator(hass=hass, api_client=api_client) - sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)] - - async_add_entities(sensor, True) + async_add_entities( + new_entities=[ + TautulliSensor( + coordinator=coordinator, + name=name, + monitored_conditions=monitored_conditions, + usernames=users, + ) + ], + update_before_add=True, + ) -class TautulliSensor(SensorEntity): +class TautulliSensor(CoordinatorEntity, SensorEntity): """Representation of a Tautulli sensor.""" - def __init__(self, tautulli, name, monitored_conditions, users): + coordinator: TautulliDataUpdateCoordinator + + def __init__( + self, + coordinator: TautulliDataUpdateCoordinator, + name: str, + monitored_conditions: list[str], + usernames: list[str], + ) -> None: """Initialize the Tautulli sensor.""" - self.tautulli = tautulli + super().__init__(coordinator) self.monitored_conditions = monitored_conditions - self.usernames = users - self.sessions = {} - self.home = {} - self._attributes = {} + self.usernames = usernames self._name = name - self._state = None - async def async_update(self): - """Get the latest data from the Tautulli API.""" - await self.tautulli.async_update() + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if not self.coordinator.activity: + return 0 + return self.coordinator.activity.stream_count or 0 + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return "mdi:plex" + + @property + def native_unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "Watching" + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return attributes for the sensor.""" if ( - not self.tautulli.activity - or not self.tautulli.home_stats - or not self.tautulli.users + not self.coordinator.activity + or not self.coordinator.home_stats + or not self.coordinator.users ): - return + return None - self._attributes = { - "stream_count": self.tautulli.activity.stream_count, - "stream_count_direct_play": self.tautulli.activity.stream_count_direct_play, - "stream_count_direct_stream": self.tautulli.activity.stream_count_direct_stream, - "stream_count_transcode": self.tautulli.activity.stream_count_transcode, - "total_bandwidth": self.tautulli.activity.total_bandwidth, - "lan_bandwidth": self.tautulli.activity.lan_bandwidth, - "wan_bandwidth": self.tautulli.activity.wan_bandwidth, + _attributes = { + "stream_count": self.coordinator.activity.stream_count, + "stream_count_direct_play": self.coordinator.activity.stream_count_direct_play, + "stream_count_direct_stream": self.coordinator.activity.stream_count_direct_stream, + "stream_count_transcode": self.coordinator.activity.stream_count_transcode, + "total_bandwidth": self.coordinator.activity.total_bandwidth, + "lan_bandwidth": self.coordinator.activity.lan_bandwidth, + "wan_bandwidth": self.coordinator.activity.wan_bandwidth, } - for stat in self.tautulli.home_stats: + for stat in self.coordinator.home_stats: if stat.stat_id == "top_movies": - self._attributes["Top Movie"] = ( - stat.rows[0].title if stat.rows else None - ) + _attributes["Top Movie"] = stat.rows[0].title if stat.rows else None elif stat.stat_id == "top_tv": - self._attributes["Top TV Show"] = ( - stat.rows[0].title if stat.rows else None - ) + _attributes["Top TV Show"] = stat.rows[0].title if stat.rows else None elif stat.stat_id == "top_users": - self._attributes["Top User"] = stat.rows[0].user if stat.rows else None + _attributes["Top User"] = stat.rows[0].user if stat.rows else None - for user in self.tautulli.users: + for user in self.coordinator.users: if ( self.usernames and user.username not in self.usernames or user.username == "Local" ): continue - self._attributes.setdefault(user.username, {})["Activity"] = None + _attributes.setdefault(user.username, {})["Activity"] = None - for session in self.tautulli.activity.sessions: - if not self._attributes.get(session.username): + for session in self.coordinator.activity.sessions: + if not _attributes.get(session.username): continue - self._attributes[session.username]["Activity"] = session.state - if self.monitored_conditions: - for key in self.monitored_conditions: - self._attributes[session.username][key] = getattr(session, key) + _attributes[session.username]["Activity"] = session.state + for key in self.monitored_conditions: + _attributes[session.username][key] = getattr(session, key) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - if not self.tautulli.activity: - return 0 - return self.tautulli.activity.stream_count - - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:plex" - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return "Watching" - - @property - def extra_state_attributes(self): - """Return attributes for the sensor.""" - return self._attributes - - -class TautulliData: - """Get the latest data and update the states.""" - - def __init__(self, api): - """Initialize the data object.""" - self.api = api - self.activity = None - self.home_stats = None - self.users = None - - @Throttle(TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from Tautulli.""" - self.activity = await self.api.async_get_activity() - self.home_stats = await self.api.async_get_home_stats() - self.users = await self.api.async_get_users() + return _attributes diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 7fd6cb24efd..c1e86129ebb 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -1,16 +1,13 @@ """Support for Telegram bots using webhooks.""" import datetime as dt +from http import HTTPStatus from ipaddress import ip_address import logging from telegram.error import TimedOut from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, - HTTP_BAD_REQUEST, - HTTP_UNAUTHORIZED, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.network import get_url from . import ( @@ -33,9 +30,8 @@ async def async_setup_platform(hass, config): bot = initialize_bot(config) current_status = await hass.async_add_executor_job(bot.getWebhookInfo) - base_url = config.get( - CONF_URL, get_url(hass, require_ssl=True, allow_internal=False) - ) + if not (base_url := config.get(CONF_URL)): + base_url = get_url(hass, require_ssl=True, allow_internal=False) # Some logging of Bot current status: last_error_date = getattr(current_status, "last_error_date", None) @@ -99,13 +95,13 @@ class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity): real_ip = ip_address(request.remote) if not any(real_ip in net for net in self.trusted_networks): _LOGGER.warning("Access denied from %s", real_ip) - return self.json_message("Access denied", HTTP_UNAUTHORIZED) + return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) if not self.process_message(data): - return self.json_message("Invalid message", HTTP_BAD_REQUEST) + return self.json_message("Invalid message", HTTPStatus.BAD_REQUEST) return None diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 35fc6809523..729b6052507 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -1,6 +1,8 @@ """Support for Tellstick Net/Telstick Live sensors.""" +from __future__ import annotations + from homeassistant.components import sensor, tellduslive -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -31,29 +33,72 @@ SENSOR_TYPE_LUMINANCE = "lum" SENSOR_TYPE_DEW_POINT = "dewp" SENSOR_TYPE_BAROMETRIC_PRESSURE = "barpress" -SENSOR_TYPES = { - SENSOR_TYPE_TEMPERATURE: [ - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - SENSOR_TYPE_HUMIDITY: ["Humidity", PERCENTAGE, None, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_RAINRATE: [ - "Rain rate", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:water", - None, - ], - SENSOR_TYPE_RAINTOTAL: ["Rain total", LENGTH_MILLIMETERS, "mdi:water", None], - SENSOR_TYPE_WINDDIRECTION: ["Wind direction", "", "", None], - SENSOR_TYPE_WINDAVERAGE: ["Wind average", SPEED_METERS_PER_SECOND, "", None], - SENSOR_TYPE_WINDGUST: ["Wind gust", SPEED_METERS_PER_SECOND, "", None], - SENSOR_TYPE_UV: ["UV", UV_INDEX, "", None], - SENSOR_TYPE_WATT: ["Power", POWER_WATT, "", None], - SENSOR_TYPE_LUMINANCE: ["Luminance", LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], - SENSOR_TYPE_DEW_POINT: ["Dew Point", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - SENSOR_TYPE_BAROMETRIC_PRESSURE: ["Barometric Pressure", "kPa", "", None], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + SENSOR_TYPE_TEMPERATURE: SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SENSOR_TYPE_HUMIDITY: SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SENSOR_TYPE_RAINRATE: SensorEntityDescription( + key=SENSOR_TYPE_RAINRATE, + name="Rain rate", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:water", + ), + SENSOR_TYPE_RAINTOTAL: SensorEntityDescription( + key=SENSOR_TYPE_RAINTOTAL, + name="Rain total", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:water", + ), + SENSOR_TYPE_WINDDIRECTION: SensorEntityDescription( + key=SENSOR_TYPE_WINDDIRECTION, + name="Wind direction", + ), + SENSOR_TYPE_WINDAVERAGE: SensorEntityDescription( + key=SENSOR_TYPE_WINDAVERAGE, + name="Wind average", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + SENSOR_TYPE_WINDGUST: SensorEntityDescription( + key=SENSOR_TYPE_WINDGUST, + name="Wind gust", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + SENSOR_TYPE_UV: SensorEntityDescription( + key=SENSOR_TYPE_UV, + name="UV", + native_unit_of_measurement=UV_INDEX, + ), + SENSOR_TYPE_WATT: SensorEntityDescription( + key=SENSOR_TYPE_WATT, + name="Power", + native_unit_of_measurement=POWER_WATT, + ), + SENSOR_TYPE_LUMINANCE: SensorEntityDescription( + key=SENSOR_TYPE_LUMINANCE, + name="Luminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SENSOR_TYPE_DEW_POINT: SensorEntityDescription( + key=SENSOR_TYPE_DEW_POINT, + name="Dew Point", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SENSOR_TYPE_BAROMETRIC_PRESSURE: SensorEntityDescription( + key=SENSOR_TYPE_BAROMETRIC_PRESSURE, + name="Barometric Pressure", + native_unit_of_measurement="kPa", + ), } @@ -75,6 +120,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): """Representation of a Telldus Live sensor.""" + def __init__(self, client, device_id): + """Initialize TelldusLiveSensor.""" + super().__init__(client, device_id) + if desc := SENSOR_TYPES.get(self._type): + self.entity_description = desc + @property def device_id(self): """Return id of the device.""" @@ -108,7 +159,10 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(super().name, self.quantity_name or "").strip() + quantity_name = ( + self.entity_description.name if hasattr(self, "entity_description") else "" + ) + return "{} {}".format(super().name, quantity_name or "").strip() @property def native_value(self): @@ -123,26 +177,6 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): return self._value_as_luminance return self._value - @property - def quantity_name(self): - """Name of quantity.""" - return SENSOR_TYPES[self._type][0] if self._type in SENSOR_TYPES else None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][1] if self._type in SENSOR_TYPES else None - - @property - def icon(self): - """Return the icon.""" - return SENSOR_TYPES[self._type][2] if self._type in SENSOR_TYPES else None - - @property - def device_class(self): - """Return the device class.""" - return SENSOR_TYPES[self._type][3] if self._type in SENSOR_TYPES else None - @property def unique_id(self) -> str: """Return a unique ID.""" diff --git a/homeassistant/components/tellduslive/translations/fr.json b/homeassistant/components/tellduslive/translations/fr.json index 02e05d2c869..9dd1a8cd3f8 100644 --- a/homeassistant/components/tellduslive/translations/fr.json +++ b/homeassistant/components/tellduslive/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "TelldusLive est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "unknown": "Une erreur inconnue s'est produite", + "unknown": "Erreur inattendue", "unknown_authorize_url_generation": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation." }, "error": { @@ -16,7 +16,7 @@ }, "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Vide", "title": "Choisissez le point de terminaison." diff --git a/homeassistant/components/tellduslive/translations/he.json b/homeassistant/components/tellduslive/translations/he.json index d19fa6d3d31..6a1c8a23c65 100644 --- a/homeassistant/components/tellduslive/translations/he.json +++ b/homeassistant/components/tellduslive/translations/he.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", "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.", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." }, "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" diff --git a/homeassistant/components/tellduslive/translations/hu.json b/homeassistant/components/tellduslive/translations/hu.json index 207e9ada090..a07259b67f9 100644 --- a/homeassistant/components/tellduslive/translations/hu.json +++ b/homeassistant/components/tellduslive/translations/hu.json @@ -11,15 +11,15 @@ }, "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} )", + "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\u00e9lyezzeie kell **{app_name}** (kattintson a ** Yes ** gombra).\n 4. J\u00f6jj\u00f6n vissza ide, \u00e9s kattintson a ** K\u00fcld\u00e9s ** gombra. \n\n [Link TelldusLive-fi\u00f3k]({auth_url})", "title": "Hiteles\u00edtsen a TelldusLive-on" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00dcres", - "title": "V\u00e1lassz v\u00e9gpontot." + "title": "V\u00e1lasszon v\u00e9gpontot." } } } diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 3e34b927971..9a0fa5a7320 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from typing import Callable from homeassistant import config as conf_util from homeassistant.const import ( diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 2706b2d433d..2cb830e54c2 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -1,4 +1,5 @@ """Support for Template alarm control panels.""" +from enum import Enum import logging import voluptuous as vol @@ -6,6 +7,7 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( ENTITY_ID_FORMAT, FORMAT_NUMBER, + FORMAT_TEXT, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, AlarmControlPanelEntity, ) @@ -54,6 +56,16 @@ CONF_ARM_NIGHT_ACTION = "arm_night" CONF_DISARM_ACTION = "disarm" CONF_ALARM_CONTROL_PANELS = "panels" CONF_CODE_ARM_REQUIRED = "code_arm_required" +CONF_CODE_FORMAT = "code_format" + + +class CodeFormat(Enum): + """Class to represent different code formats.""" + + no_code = None + number = FORMAT_NUMBER + text = FORMAT_TEXT + ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( { @@ -63,6 +75,9 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=CodeFormat.number.name): cv.enum( + CodeFormat + ), vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -89,6 +104,7 @@ async def _async_create_entities(hass, config): arm_home_action = device_config.get(CONF_ARM_HOME_ACTION) arm_night_action = device_config.get(CONF_ARM_NIGHT_ACTION) code_arm_required = device_config[CONF_CODE_ARM_REQUIRED] + code_format = device_config[CONF_CODE_FORMAT] unique_id = device_config.get(CONF_UNIQUE_ID) alarm_control_panels.append( @@ -102,6 +118,7 @@ async def _async_create_entities(hass, config): arm_home_action, arm_night_action, code_arm_required, + code_format, unique_id, ) ) @@ -128,6 +145,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): arm_home_action, arm_night_action, code_arm_required, + code_format, unique_id, ): """Initialize the panel.""" @@ -139,6 +157,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): self._template = state_template self._disarm_script = None self._code_arm_required = code_arm_required + self._code_format = code_format domain = __name__.split(".")[-2] if disarm_action is not None: self._disarm_script = Script(hass, disarm_action, name, domain) @@ -187,8 +206,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): @property def code_format(self): - """Return one or more digits/characters.""" - return FORMAT_NUMBER + """Regex for code format or None if no code is required.""" + return self._code_format.value @property def code_arm_required(self): diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 0309321afbc..54d213be0b1 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -27,4 +27,3 @@ CONF_AVAILABILITY = "availability" CONF_ATTRIBUTES = "attributes" CONF_PICTURE = "picture" CONF_OBJECT_ID = "object_id" -CONF_STATE_CLASS = "state_class" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index d51b18e294b..4214323c8ee 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, @@ -38,7 +39,6 @@ from .const import ( CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID, CONF_PICTURE, - CONF_STATE_CLASS, CONF_TRIGGER, ) from .template_entity import TemplateEntity diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 6bf889ebf02..42517b00d4a 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,8 +1,9 @@ """TemplateEntity utility class.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 6db25da76ab..d2e50de53fd 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -31,7 +31,7 @@ async def async_attach_trigger( hass, config, action, automation_info, *, platform_type="template" ): """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass time_delta = config.get(CONF_FOR) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index b3162a19364..391eeb1cf87 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.21.1", + "numpy==1.21.2", "pillow==8.2.0" ], "codeowners": [], diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py deleted file mode 100644 index 798e769dc47..00000000000 --- a/homeassistant/components/tesla/__init__.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Support for Tesla cars.""" -import asyncio -from collections import defaultdict -from datetime import timedelta -import logging - -import async_timeout -import httpx -from teslajsonpy import Controller as TeslaAPI -from teslajsonpy.exceptions import IncompleteCredentials, TeslaException -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - ATTR_BATTERY_CHARGING, - ATTR_BATTERY_LEVEL, - CONF_ACCESS_TOKEN, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - EVENT_HOMEASSISTANT_CLOSE, - HTTP_UNAUTHORIZED, -) -from homeassistant.core import callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.httpx_client import SERVER_SOFTWARE, USER_AGENT -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.util import slugify - -from .config_flow import CannotConnect, InvalidAuth, validate_input -from .const import ( - CONF_EXPIRATION, - CONF_WAKE_ON_START, - DATA_LISTENER, - DEFAULT_SCAN_INTERVAL, - DEFAULT_WAKE_ON_START, - DOMAIN, - ICONS, - MIN_SCAN_INTERVAL, - PLATFORMS, -) - -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -@callback -def _async_save_tokens(hass, config_entry, access_token, refresh_token): - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_ACCESS_TOKEN: access_token, - CONF_TOKEN: refresh_token, - }, - ) - - -@callback -def _async_configured_emails(hass): - """Return a set of configured Tesla emails.""" - return { - entry.data[CONF_USERNAME] - for entry in hass.config_entries.async_entries(DOMAIN) - if CONF_USERNAME in entry.data - } - - -async def async_setup(hass, base_config): - """Set up of Tesla component.""" - - def _update_entry(email, data=None, options=None): - data = data or {} - options = options or { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, - } - for entry in hass.config_entries.async_entries(DOMAIN): - if email != entry.title: - continue - hass.config_entries.async_update_entry(entry, data=data, options=options) - - config = base_config.get(DOMAIN) - if not config: - return True - email = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - scan_interval = config[CONF_SCAN_INTERVAL] - if email in _async_configured_emails(hass): - try: - info = await validate_input(hass, config) - except (CannotConnect, InvalidAuth): - return False - _update_entry( - email, - data={ - CONF_USERNAME: email, - CONF_PASSWORD: password, - CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN], - CONF_TOKEN: info[CONF_TOKEN], - CONF_EXPIRATION: info[CONF_EXPIRATION], - }, - options={CONF_SCAN_INTERVAL: scan_interval}, - ) - else: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: email, CONF_PASSWORD: password}, - ) - ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][email] = {CONF_SCAN_INTERVAL: scan_interval} - return True - - -async def async_setup_entry(hass, config_entry): - """Set up Tesla as config entry.""" - hass.data.setdefault(DOMAIN, {}) - config = config_entry.data - # Because users can have multiple accounts, we always create a new session so they have separate cookies - async_client = httpx.AsyncClient(headers={USER_AGENT: SERVER_SOFTWARE}, timeout=60) - email = config_entry.title - if email in hass.data[DOMAIN] and CONF_SCAN_INTERVAL in hass.data[DOMAIN][email]: - scan_interval = hass.data[DOMAIN][email][CONF_SCAN_INTERVAL] - hass.config_entries.async_update_entry( - config_entry, options={CONF_SCAN_INTERVAL: scan_interval} - ) - hass.data[DOMAIN].pop(email) - try: - controller = TeslaAPI( - async_client, - email=config.get(CONF_USERNAME), - password=config.get(CONF_PASSWORD), - refresh_token=config[CONF_TOKEN], - access_token=config[CONF_ACCESS_TOKEN], - expiration=config.get(CONF_EXPIRATION, 0), - update_interval=config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ) - result = await controller.connect( - wake_if_asleep=config_entry.options.get( - CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START - ) - ) - refresh_token = result["refresh_token"] - access_token = result["access_token"] - except IncompleteCredentials as ex: - await async_client.aclose() - raise ConfigEntryAuthFailed from ex - except httpx.ConnectTimeout as ex: - await async_client.aclose() - raise ConfigEntryNotReady from ex - except TeslaException as ex: - await async_client.aclose() - if ex.code == HTTP_UNAUTHORIZED: - raise ConfigEntryAuthFailed from ex - if ex.message in [ - "VEHICLE_UNAVAILABLE", - "TOO_MANY_REQUESTS", - "SERVICE_MAINTENANCE", - "UPSTREAM_TIMEOUT", - ]: - raise ConfigEntryNotReady( - f"Temporarily unable to communicate with Tesla API: {ex.message}" - ) from ex - _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) - return False - - async def _async_close_client(*_): - await async_client.aclose() - - @callback - def _async_create_close_task(): - asyncio.create_task(_async_close_client()) - - config_entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client) - ) - config_entry.async_on_unload(_async_create_close_task) - - _async_save_tokens(hass, config_entry, access_token, refresh_token) - coordinator = TeslaDataUpdateCoordinator( - hass, config_entry=config_entry, controller=controller - ) - # Fetch initial data so we have data when entities subscribe - entry_data = hass.data[DOMAIN][config_entry.entry_id] = { - "coordinator": coordinator, - "devices": defaultdict(list), - DATA_LISTENER: [config_entry.add_update_listener(update_listener)], - } - _LOGGER.debug("Connected to the Tesla API") - - await coordinator.async_config_entry_first_refresh() - - all_devices = controller.get_homeassistant_components() - - if not all_devices: - return False - - for device in all_devices: - entry_data["devices"][device.hass_type].append(device) - - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass, config_entry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - for listener in hass.data[DOMAIN][config_entry.entry_id][DATA_LISTENER]: - listener() - username = config_entry.title - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - _LOGGER.debug("Unloaded entry for %s", username) - return True - return False - - -async def update_listener(hass, config_entry): - """Update when config_entry options update.""" - controller = hass.data[DOMAIN][config_entry.entry_id]["coordinator"].controller - old_update_interval = controller.update_interval - controller.update_interval = config_entry.options.get(CONF_SCAN_INTERVAL) - if old_update_interval != controller.update_interval: - _LOGGER.debug( - "Changing scan_interval from %s to %s", - old_update_interval, - controller.update_interval, - ) - - -class TeslaDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Tesla data.""" - - def __init__(self, hass, *, config_entry, controller): - """Initialize global Tesla data updater.""" - self.controller = controller - self.config_entry = config_entry - - update_interval = timedelta(seconds=MIN_SCAN_INTERVAL) - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, - ) - - async def _async_update_data(self): - """Fetch data from API endpoint.""" - if self.controller.is_token_refreshed(): - result = self.controller.get_tokens() - refresh_token = result["refresh_token"] - access_token = result["access_token"] - _async_save_tokens( - self.hass, self.config_entry, access_token, refresh_token - ) - _LOGGER.debug("Saving new tokens in config_entry") - - try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with async_timeout.timeout(30): - return await self.controller.update() - except TeslaException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - -class TeslaDevice(CoordinatorEntity): - """Representation of a Tesla device.""" - - def __init__(self, tesla_device, coordinator): - """Initialise the Tesla device.""" - super().__init__(coordinator) - self.tesla_device = tesla_device - self._name = self.tesla_device.name - self._unique_id = slugify(self.tesla_device.uniq_name) - self._attributes = self.tesla_device.attrs.copy() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def icon(self): - """Return the icon of the sensor.""" - if self.device_class: - return None - - return ICONS.get(self.tesla_device.type) - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attr = self._attributes - if self.tesla_device.has_battery(): - attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() - attr[ATTR_BATTERY_CHARGING] = self.tesla_device.battery_charging() - return attr - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.tesla_device.id())}, - "name": self.tesla_device.car_name(), - "manufacturer": "Tesla", - "model": self.tesla_device.car_type, - "sw_version": self.tesla_device.car_version, - } - - async def async_added_to_hass(self): - """Register state update callback.""" - self.async_on_remove(self.coordinator.async_add_listener(self.refresh)) - - @callback - def refresh(self) -> None: - """Refresh the state of the device. - - This assumes the coordinator has updated the controller. - """ - self.tesla_device.refresh() - self._attributes = self.tesla_device.attrs.copy() - self.async_write_ha_state() diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py deleted file mode 100644 index 77315ef1e3c..00000000000 --- a/homeassistant/components/tesla/binary_sensor.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Support for Tesla binary sensor.""" - -from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - async_add_entities( - [ - TeslaBinarySensor( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ - "binary_sensor" - ] - ], - True, - ) - - -class TeslaBinarySensor(TeslaDevice, BinarySensorEntity): - """Implement an Tesla binary sensor for parking and charger.""" - - @property - def device_class(self): - """Return the class of this binary sensor.""" - return ( - self.tesla_device.sensor_type - if self.tesla_device.sensor_type in DEVICE_CLASSES - else None - ) - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self.tesla_device.get_value() diff --git a/homeassistant/components/tesla/climate.py b/homeassistant/components/tesla/climate.py deleted file mode 100644 index 81639bc3fe4..00000000000 --- a/homeassistant/components/tesla/climate.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Support for Tesla HVAC system.""" -from __future__ import annotations - -import logging - -from teslajsonpy.exceptions import UnknownPresetMode - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE, -) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_HVAC = [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF] - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - async_add_entities( - [ - TeslaThermostat( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ - "climate" - ] - ], - True, - ) - - -class TeslaThermostat(TeslaDevice, ClimateEntity): - """Representation of a Tesla climate.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self.tesla_device.is_hvac_enabled(): - return HVAC_MODE_HEAT_COOL - return HVAC_MODE_OFF - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return SUPPORT_HVAC - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - if self.tesla_device.measurement == "F": - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.tesla_device.get_current_temp() - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.tesla_device.get_goal_temp() - - async def async_set_temperature(self, **kwargs): - """Set new target temperatures.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature: - _LOGGER.debug("%s: Setting temperature to %s", self.name, temperature) - await self.tesla_device.set_temperature(temperature) - - async def async_set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - _LOGGER.debug("%s: Setting hvac mode to %s", self.name, hvac_mode) - if hvac_mode == HVAC_MODE_OFF: - await self.tesla_device.set_status(False) - elif hvac_mode == HVAC_MODE_HEAT_COOL: - await self.tesla_device.set_status(True) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - _LOGGER.debug("%s: Setting preset_mode to: %s", self.name, preset_mode) - try: - await self.tesla_device.set_preset_mode(preset_mode) - except UnknownPresetMode as ex: - _LOGGER.error("%s", ex.message) - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires SUPPORT_PRESET_MODE. - """ - return self.tesla_device.preset_mode - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes. - - Requires SUPPORT_PRESET_MODE. - """ - return self.tesla_device.preset_modes diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py deleted file mode 100644 index 5a88999a7e3..00000000000 --- a/homeassistant/components/tesla/config_flow.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tesla Config Flow.""" -import logging - -import httpx -from teslajsonpy import Controller as TeslaAPI, TeslaException -from teslajsonpy.exceptions import IncompleteCredentials -import voluptuous as vol - -from homeassistant import config_entries, core, exceptions -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - HTTP_UNAUTHORIZED, -) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -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, - DOMAIN, - MIN_SCAN_INTERVAL, -) - -_LOGGER = logging.getLogger(__name__) - - -class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Tesla.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize the tesla flow.""" - self.username = None - self.reauth = False - - 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): - """Handle the start of the config flow.""" - errors = {} - - if user_input is not None: - existing_entry = self._async_entry_for_username(user_input[CONF_USERNAME]) - if existing_entry and not self.reauth: - return self.async_abort(reason="already_configured") - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - - if not errors: - if existing_entry: - self.hass.config_entries.async_update_entry( - existing_entry, data=info - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=info - ) - - return self.async_show_form( - step_id="user", - data_schema=self._async_schema(), - errors=errors, - description_placeholders={}, - ) - - async def async_step_reauth(self, data): - """Handle configuration by re-auth.""" - self.username = data[CONF_USERNAME] - self.reauth = True - return await self.async_step_user() - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - @callback - def _async_schema(self): - """Fetch schema with defaults.""" - return vol.Schema( - { - vol.Required(CONF_USERNAME, default=self.username): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_MFA): str, - } - ) - - @callback - def _async_entry_for_username(self, username): - """Find an existing entry for a username.""" - for entry in self._async_current_entries(): - if entry.data.get(CONF_USERNAME) == username: - return entry - return None - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for Tesla.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - data_schema = vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), - vol.Optional( - CONF_WAKE_ON_START, - default=self.config_entry.options.get( - CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START - ), - ): bool, - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - config = {} - async_client = httpx.AsyncClient(headers={USER_AGENT: SERVER_SOFTWARE}, timeout=60) - - try: - controller = TeslaAPI( - async_client, - email=data[CONF_USERNAME], - password=data[CONF_PASSWORD], - update_interval=DEFAULT_SCAN_INTERVAL, - ) - 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] - config[CONF_USERNAME] = data[CONF_USERNAME] - config[CONF_PASSWORD] = data[CONF_PASSWORD] - except IncompleteCredentials as ex: - _LOGGER.error("Authentication error: %s %s", ex.message, ex) - raise InvalidAuth() from ex - except TeslaException as ex: - if ex.code == HTTP_UNAUTHORIZED: - _LOGGER.error("Invalid credentials: %s", ex) - raise InvalidAuth() from ex - _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) - raise CannotConnect() from ex - finally: - await async_client.aclose() - _LOGGER.debug("Credentials successfully connected to the Tesla API") - return config - - -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/tesla/const.py b/homeassistant/components/tesla/const.py deleted file mode 100644 index c288b3c1cda..00000000000 --- a/homeassistant/components/tesla/const.py +++ /dev/null @@ -1,33 +0,0 @@ -"""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 -DEFAULT_WAKE_ON_START = False -MIN_SCAN_INTERVAL = 60 - -PLATFORMS = [ - "sensor", - "lock", - "climate", - "binary_sensor", - "device_tracker", - "switch", -] - -ICONS = { - "battery sensor": "mdi:battery", - "range sensor": "mdi:gauge", - "mileage sensor": "mdi:counter", - "parking brake sensor": "mdi:car-brake-parking", - "charger sensor": "mdi:ev-station", - "charger switch": "mdi:battery-charging", - "update switch": "mdi:update", - "maxrange switch": "mdi:gauge-full", - "temperature sensor": "mdi:thermometer", - "location tracker": "mdi:crosshairs-gps", - "charging rate sensor": "mdi:speedometer", - "sentry mode switch": "mdi:shield-car", -} diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py deleted file mode 100644 index 6813b3769e7..00000000000 --- a/homeassistant/components/tesla/device_tracker.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Support for tracking Tesla cars.""" -from __future__ import annotations - -from homeassistant.components.device_tracker import SOURCE_TYPE_GPS -from homeassistant.components.device_tracker.config_entry import TrackerEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - entities = [ - TeslaDeviceEntity( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ - "devices_tracker" - ] - ] - async_add_entities(entities, True) - - -class TeslaDeviceEntity(TeslaDevice, TrackerEntity): - """A class representing a Tesla device.""" - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - location = self.tesla_device.get_location() - return self.tesla_device.get_location().get("latitude") if location else None - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - location = self.tesla_device.get_location() - return self.tesla_device.get_location().get("longitude") if location else None - - @property - def source_type(self): - """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_GPS - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attr = super().extra_state_attributes.copy() - location = self.tesla_device.get_location() - if location: - attr.update( - { - "trackr_id": self.unique_id, - "heading": location["heading"], - "speed": location["speed"], - } - ) - return attr diff --git a/homeassistant/components/tesla/lock.py b/homeassistant/components/tesla/lock.py deleted file mode 100644 index 7a74d2ececb..00000000000 --- a/homeassistant/components/tesla/lock.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Support for Tesla door locks.""" -import logging - -from homeassistant.components.lock import LockEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - entities = [ - TeslaLock( - device, - hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"], - ) - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["lock"] - ] - async_add_entities(entities, True) - - -class TeslaLock(TeslaDevice, LockEntity): - """Representation of a Tesla door lock.""" - - async def async_lock(self, **kwargs): - """Send the lock command.""" - _LOGGER.debug("Locking doors for: %s", self.name) - await self.tesla_device.lock() - - async def async_unlock(self, **kwargs): - """Send the unlock command.""" - _LOGGER.debug("Unlocking doors for: %s", self.name) - await self.tesla_device.unlock() - - @property - def is_locked(self): - """Get whether the lock is in locked state.""" - if self.tesla_device.is_locked() is None: - return None - return self.tesla_device.is_locked() diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json deleted file mode 100644 index 8604436d5a4..00000000000 --- a/homeassistant/components/tesla/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "domain": "tesla", - "name": "Tesla", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.18.3"], - "codeowners": ["@zabuldon", "@alandtse"], - "dhcp": [ - { - "hostname": "tesla_*", - "macaddress": "4CFCAA*" - }, - { - "hostname": "tesla_*", - "macaddress": "044EAF*" - }, - { - "hostname": "tesla_*", - "macaddress": "98ED5C*" - } - ], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py deleted file mode 100644 index 60e3e19047d..00000000000 --- a/homeassistant/components/tesla/sensor.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Support for the Tesla sensors.""" -from __future__ import annotations - -from homeassistant.components.sensor import DEVICE_CLASSES, SensorEntity -from homeassistant.const import ( - LENGTH_KILOMETERS, - LENGTH_MILES, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.util.distance import convert - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"] - entities = [] - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["sensor"]: - if device.type == "temperature sensor": - entities.append(TeslaSensor(device, coordinator, "inside")) - entities.append(TeslaSensor(device, coordinator, "outside")) - else: - entities.append(TeslaSensor(device, coordinator)) - async_add_entities(entities, True) - - -class TeslaSensor(TeslaDevice, SensorEntity): - """Representation of Tesla sensors.""" - - def __init__(self, tesla_device, coordinator, sensor_type=None): - """Initialize of the sensor.""" - super().__init__(tesla_device, coordinator) - self.type = sensor_type - if self.type: - self._name = f"{super().name} ({self.type})" - self._unique_id = f"{super().unique_id}_{self.type}" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - if self.tesla_device.type == "temperature sensor": - if self.type == "outside": - return self.tesla_device.get_outside_temp() - return self.tesla_device.get_inside_temp() - if self.tesla_device.type in ("range sensor", "mileage sensor"): - units = self.tesla_device.measurement - if units == "LENGTH_MILES": - return self.tesla_device.get_value() - return round( - convert(self.tesla_device.get_value(), LENGTH_MILES, LENGTH_KILOMETERS), - 2, - ) - if self.tesla_device.type == "charging rate sensor": - return self.tesla_device.charging_rate - return self.tesla_device.get_value() - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit_of_measurement of the device.""" - units = self.tesla_device.measurement - if units == "F": - return TEMP_FAHRENHEIT - if units == "C": - return TEMP_CELSIUS - if units == "LENGTH_MILES": - return LENGTH_MILES - if units == "LENGTH_KILOMETERS": - return LENGTH_KILOMETERS - return units - - @property - def device_class(self) -> str | None: - """Return the device_class of the device.""" - return ( - self.tesla_device.device_class - if self.tesla_device.device_class in DEVICE_CLASSES - else None - ) - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - attr = self._attributes.copy() - if self.tesla_device.type == "charging rate sensor": - attr.update( - { - "time_left": self.tesla_device.time_left, - "added_range": self.tesla_device.added_range, - "charge_energy_added": self.tesla_device.charge_energy_added, - "charge_current_request": self.tesla_device.charge_current_request, - "charger_actual_current": self.tesla_device.charger_actual_current, - "charger_voltage": self.tesla_device.charger_voltage, - } - ) - return attr diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json deleted file mode 100644 index 0f5a7666175..00000000000 --- a/homeassistant/components/tesla/strings.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" - }, - "abort": { - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" - }, - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]", - "mfa": "MFA Code (Optional)" - }, - "description": "Please enter your information.", - "title": "Tesla - Configuration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Seconds between scans", - "enable_wake_on_start": "Force cars awake on startup" - } - } - } - } -} diff --git a/homeassistant/components/tesla/switch.py b/homeassistant/components/tesla/switch.py deleted file mode 100644 index efcb955ebf8..00000000000 --- a/homeassistant/components/tesla/switch.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Support for Tesla charger switches.""" -import logging - -from homeassistant.components.switch import SwitchEntity - -from . import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Tesla binary_sensors by config_entry.""" - coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"] - entities = [] - for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["switch"]: - if device.type == "charger switch": - entities.append(ChargerSwitch(device, coordinator)) - entities.append(UpdateSwitch(device, coordinator)) - elif device.type == "maxrange switch": - entities.append(RangeSwitch(device, coordinator)) - elif device.type == "sentry mode switch": - entities.append(SentryModeSwitch(device, coordinator)) - async_add_entities(entities, True) - - -class ChargerSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla charger switch.""" - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable charging: %s", self.name) - await self.tesla_device.start_charge() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable charging for: %s", self.name) - await self.tesla_device.stop_charge() - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.tesla_device.is_charging() is None: - return None - return self.tesla_device.is_charging() - - -class RangeSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla max range charging switch.""" - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable max range charging: %s", self.name) - await self.tesla_device.set_max() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable max range charging: %s", self.name) - await self.tesla_device.set_standard() - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.tesla_device.is_maxrange() is None: - return None - return bool(self.tesla_device.is_maxrange()) - - -class UpdateSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla update switch.""" - - def __init__(self, tesla_device, coordinator): - """Initialise the switch.""" - super().__init__(tesla_device, coordinator) - self.controller = coordinator.controller - - @property - def name(self): - """Return the name of the device.""" - return super().name.replace("charger", "update") - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return super().unique_id.replace("charger", "update") - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable updates: %s %s", self.name, self.tesla_device.id()) - self.controller.set_updates(self.tesla_device.id(), True) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable updates: %s %s", self.name, self.tesla_device.id()) - self.controller.set_updates(self.tesla_device.id(), False) - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.controller.get_updates(self.tesla_device.id()) is None: - return None - return bool(self.controller.get_updates(self.tesla_device.id())) - - -class SentryModeSwitch(TeslaDevice, SwitchEntity): - """Representation of a Tesla sentry mode switch.""" - - async def async_turn_on(self, **kwargs): - """Send the on command.""" - _LOGGER.debug("Enable sentry mode: %s", self.name) - await self.tesla_device.enable_sentry_mode() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs): - """Send the off command.""" - _LOGGER.debug("Disable sentry mode: %s", self.name) - await self.tesla_device.disable_sentry_mode() - self.async_write_ha_state() - - @property - def is_on(self): - """Get whether the switch is in on state.""" - if self.tesla_device.is_on() is None: - return None - return self.tesla_device.is_on() diff --git a/homeassistant/components/tesla/translations/ca.json b/homeassistant/components/tesla/translations/ca.json deleted file mode 100644 index f5c0117f6a0..00000000000 --- a/homeassistant/components/tesla/translations/ca.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El compte ja ha estat configurat", - "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" - }, - "error": { - "already_configured": "El compte ja ha estat configurat", - "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" - }, - "step": { - "user": { - "data": { - "mfa": "Codi MFA (opcional)", - "password": "Contrasenya", - "username": "Correu electr\u00f2nic" - }, - "description": "Introdueix la teva informaci\u00f3.", - "title": "Configuraci\u00f3 de Tesla" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "For\u00e7a el despertar del cotxe en la posada en marxa", - "scan_interval": "Segons entre escanejos" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/cs.json b/homeassistant/components/tesla/translations/cs.json deleted file mode 100644 index 9c117223d40..00000000000 --- a/homeassistant/components/tesla/translations/cs.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven", - "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" - }, - "error": { - "already_configured": "\u00da\u010det je ji\u017e nastaven", - "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", - "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" - }, - "step": { - "user": { - "data": { - "password": "Heslo", - "username": "E-mail" - }, - "description": "Zadejte sv\u00e9 \u00fadaje.", - "title": "Tesla - Nastaven\u00ed" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Po\u010det sekund mezi sledov\u00e1n\u00edm" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/da.json b/homeassistant/components/tesla/translations/da.json deleted file mode 100644 index c6cb8b5b208..00000000000 --- a/homeassistant/components/tesla/translations/da.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Adgangskode", - "username": "Email-adresse" - }, - "description": "Indtast dine oplysninger.", - "title": "Tesla - Konfiguration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Sekunder mellem scanninger" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/de.json b/homeassistant/components/tesla/translations/de.json deleted file mode 100644 index 09934369f6b..00000000000 --- a/homeassistant/components/tesla/translations/de.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto wurde bereits konfiguriert", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" - }, - "error": { - "already_configured": "Konto wurde bereits konfiguriert", - "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung" - }, - "step": { - "user": { - "data": { - "mfa": "MFA-Code (optional)", - "password": "Passwort", - "username": "E-Mail" - }, - "description": "Bitte gib deine Daten ein.", - "title": "Tesla - Konfiguration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Aufwachen des Autos beim Start erzwingen", - "scan_interval": "Sekunden zwischen den Scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/en.json b/homeassistant/components/tesla/translations/en.json deleted file mode 100644 index 16a1c185138..00000000000 --- a/homeassistant/components/tesla/translations/en.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is already configured", - "reauth_successful": "Re-authentication was successful" - }, - "error": { - "already_configured": "Account is already configured", - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication" - }, - "step": { - "user": { - "data": { - "mfa": "MFA Code (Optional)", - "password": "Password", - "username": "Email" - }, - "description": "Please enter your information.", - "title": "Tesla - Configuration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Force cars awake on startup", - "scan_interval": "Seconds between scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/es-419.json b/homeassistant/components/tesla/translations/es-419.json deleted file mode 100644 index 20fe7b3c436..00000000000 --- a/homeassistant/components/tesla/translations/es-419.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" - }, - "description": "Por favor ingrese su informaci\u00f3n.", - "title": "Tesla - Configuraci\u00f3n" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forzar a autom\u00f3viles despertar al inicio", - "scan_interval": "Segundos entre escaneos" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/es.json b/homeassistant/components/tesla/translations/es.json deleted file mode 100644 index 8211e806741..00000000000 --- a/homeassistant/components/tesla/translations/es.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya ha sido configurada", - "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" - }, - "error": { - "already_configured": "La cuenta ya ha sido configurada", - "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" - }, - "step": { - "user": { - "data": { - "mfa": "C\u00f3digo MFA (opcional)", - "password": "Contrase\u00f1a", - "username": "Correo electr\u00f3nico" - }, - "description": "Por favor, introduzca su informaci\u00f3n.", - "title": "Tesla - Configuraci\u00f3n" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forzar autom\u00f3viles despiertos al inicio", - "scan_interval": "Segundos entre escaneos" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/et.json b/homeassistant/components/tesla/translations/et.json deleted file mode 100644 index ab36a4e503d..00000000000 --- a/homeassistant/components/tesla/translations/et.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kasutaja on juba seadistatud", - "reauth_successful": "Taastuvastamine \u00f5nnestus" - }, - "error": { - "already_configured": "Konto on juba h\u00e4\u00e4lestatud", - "cannot_connect": "\u00dchendamine nurjus", - "invalid_auth": "Tuvastamise viga" - }, - "step": { - "user": { - "data": { - "mfa": "MFA kood (valikuline)", - "password": "Salas\u00f5na", - "username": "E-post" - }, - "description": "Palun sisesta oma andmed.", - "title": "Tesla - seadistamine" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Sunni autod k\u00e4ivitamisel \u00e4rkama (?)", - "scan_interval": "P\u00e4ringute vahe sekundites" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/fr.json b/homeassistant/components/tesla/translations/fr.json deleted file mode 100644 index 174b687f26f..00000000000 --- a/homeassistant/components/tesla/translations/fr.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" - }, - "error": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification invalide" - }, - "step": { - "user": { - "data": { - "mfa": "Code MFA (facultatif)", - "password": "Mot de passe", - "username": "Email" - }, - "description": "Veuillez saisir vos informations.", - "title": "Tesla - Configuration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forcer les voitures \u00e0 se r\u00e9veiller au d\u00e9marrage", - "scan_interval": "Secondes entre les scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/hu.json b/homeassistant/components/tesla/translations/hu.json deleted file mode 100644 index 75a93566df5..00000000000 --- a/homeassistant/components/tesla/translations/hu.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" - }, - "error": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", - "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" - }, - "step": { - "user": { - "data": { - "mfa": "MFA k\u00f3d (opcion\u00e1lis)", - "password": "Jelsz\u00f3", - "username": "E-mail" - }, - "description": "K\u00e9rlek, add meg az adataidat.", - "title": "Tesla - Konfigur\u00e1ci\u00f3" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Az aut\u00f3k \u00e9bred\u00e9sre k\u00e9nyszer\u00edt\u00e9se ind\u00edt\u00e1skor", - "scan_interval": "Szkennel\u00e9sek k\u00f6z\u00f6tti m\u00e1sodpercek" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/id.json b/homeassistant/components/tesla/translations/id.json deleted file mode 100644 index 681504d0d42..00000000000 --- a/homeassistant/components/tesla/translations/id.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Akun sudah dikonfigurasi", - "reauth_successful": "Autentikasi ulang berhasil" - }, - "error": { - "already_configured": "Akun sudah dikonfigurasi", - "cannot_connect": "Gagal terhubung", - "invalid_auth": "Autentikasi tidak valid" - }, - "step": { - "user": { - "data": { - "password": "Kata Sandi", - "username": "Email" - }, - "description": "Masukkan informasi Anda.", - "title": "Tesla - Konfigurasi" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Paksa mobil bangun saat dinyalakan", - "scan_interval": "Interval pemindaian dalam detik" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/it.json b/homeassistant/components/tesla/translations/it.json deleted file mode 100644 index 05a663df149..00000000000 --- a/homeassistant/components/tesla/translations/it.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" - }, - "error": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato", - "cannot_connect": "Impossibile connettersi", - "invalid_auth": "Autenticazione non valida" - }, - "step": { - "user": { - "data": { - "mfa": "Codice autenticazione a pi\u00f9 fattori MFA (facoltativo)", - "password": "Password", - "username": "E-mail" - }, - "description": "Si prega di inserire le tue informazioni.", - "title": "Tesla - Configurazione" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forza il risveglio delle auto all'avvio", - "scan_interval": "Secondi tra le scansioni" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ka.json b/homeassistant/components/tesla/translations/ka.json deleted file mode 100644 index 249c8f6cffb..00000000000 --- a/homeassistant/components/tesla/translations/ka.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "\u10d0\u10dc\u10d2\u10d0\u10e0\u10d8\u10e8\u10d8 \u10e3\u10d9\u10d5\u10d4 \u10d9\u10dd\u10dc\u10e4\u10d8\u10d2\u10e3\u10e0\u10d8\u10e0\u10d4\u10d1\u10e3\u10da\u10d8\u10d0" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ko.json b/homeassistant/components/tesla/translations/ko.json deleted file mode 100644 index 285326f39de..00000000000 --- a/homeassistant/components/tesla/translations/ko.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "reauth_successful": "\uc7ac\uc778\uc99d\uc5d0 \uc131\uacf5\ud588\uc2b5\ub2c8\ub2e4" - }, - "error": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "step": { - "user": { - "data": { - "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc774\uba54\uc77c" - }, - "description": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "Tesla - \uad6c\uc131" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\uc2dc\ub3d9 \uc2dc \ucc28\ub7c9 \uae68\uc6b0\uae30", - "scan_interval": "\uc2a4\uce94 \uac04\uaca9 (\ucd08)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/lb.json b/homeassistant/components/tesla/translations/lb.json deleted file mode 100644 index 32353c99b3e..00000000000 --- a/homeassistant/components/tesla/translations/lb.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "Kont ass scho konfigur\u00e9iert", - "cannot_connect": "Feeler beim verbannen", - "invalid_auth": "Ong\u00eblteg Authentifikatioun" - }, - "step": { - "user": { - "data": { - "password": "Passwuert", - "username": "E-Mail" - }, - "description": "F\u00ebllt \u00e4r Informatiounen aus.", - "title": "Tesla - Konfiguratioun" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forc\u00e9ier d'Erw\u00e4chen vun den Autoen beim starten", - "scan_interval": "Sekonnen t\u00ebscht Scannen" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/lv.json b/homeassistant/components/tesla/translations/lv.json deleted file mode 100644 index eab98211e14..00000000000 --- a/homeassistant/components/tesla/translations/lv.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Parole", - "username": "E-pasta adrese" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/nl.json b/homeassistant/components/tesla/translations/nl.json deleted file mode 100644 index 689766cd906..00000000000 --- a/homeassistant/components/tesla/translations/nl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is al geconfigureerd", - "reauth_successful": "Herauthenticatie was succesvol" - }, - "error": { - "already_configured": "Account is al geconfigureerd", - "cannot_connect": "Kan geen verbinding maken", - "invalid_auth": "Ongeldige authenticatie" - }, - "step": { - "user": { - "data": { - "mfa": "MFA Code (optioneel)", - "password": "Wachtwoord", - "username": "E-mail" - }, - "description": "Vul alstublieft uw gegevens in.", - "title": "Tesla - Configuratie" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Forceer auto's wakker bij het opstarten", - "scan_interval": "Seconden tussen scans" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/no.json b/homeassistant/components/tesla/translations/no.json deleted file mode 100644 index 11e49486107..00000000000 --- a/homeassistant/components/tesla/translations/no.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" - }, - "error": { - "already_configured": "Kontoen er allerede konfigurert", - "cannot_connect": "Tilkobling mislyktes", - "invalid_auth": "Ugyldig godkjenning" - }, - "step": { - "user": { - "data": { - "mfa": "MFA -kode (valgfritt)", - "password": "Passord", - "username": "E-post" - }, - "description": "Vennligst fyll inn din informasjonen.", - "title": "Tesla - Konfigurasjon" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Tving biler til \u00e5 v\u00e5kne ved oppstart", - "scan_interval": "Sekunder mellom skanninger" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/pl.json b/homeassistant/components/tesla/translations/pl.json deleted file mode 100644 index 266a0e82dbe..00000000000 --- a/homeassistant/components/tesla/translations/pl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" - }, - "error": { - "already_configured": "Konto jest ju\u017c skonfigurowane", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "invalid_auth": "Niepoprawne uwierzytelnienie" - }, - "step": { - "user": { - "data": { - "mfa": "Kod uwierzytelniania wielosk\u0142adnikowego (opcjonalnie)", - "password": "Has\u0142o", - "username": "Adres e-mail" - }, - "description": "Wprowad\u017a dane", - "title": "Tesla \u2014 konfiguracja" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Wymu\u015b wybudzenie samochod\u00f3w podczas uruchamiania", - "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/pt-BR.json b/homeassistant/components/tesla/translations/pt-BR.json deleted file mode 100644 index 1317f4b1dd7..00000000000 --- a/homeassistant/components/tesla/translations/pt-BR.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Senha", - "username": "Endere\u00e7o de e-mail" - }, - "description": "Por favor, insira suas informa\u00e7\u00f5es.", - "title": "Tesla - Configura\u00e7\u00e3o" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "For\u00e7ar carros a acordar na inicializa\u00e7\u00e3o" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/pt.json b/homeassistant/components/tesla/translations/pt.json deleted file mode 100644 index c249c325adc..00000000000 --- a/homeassistant/components/tesla/translations/pt.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "Conta j\u00e1 configurada", - "cannot_connect": "Falha na liga\u00e7\u00e3o", - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" - }, - "step": { - "user": { - "data": { - "password": "Palavra-passe", - "username": "Endere\u00e7o de e-mail" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/ru.json b/homeassistant/components/tesla/translations/ru.json deleted file mode 100644 index 191d10b8bea..00000000000 --- a/homeassistant/components/tesla/translations/ru.json +++ /dev/null @@ -1,34 +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.", - "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": { - "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.", - "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": { - "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" - }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438.", - "title": "Tesla" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u0442\u044c \u043c\u0430\u0448\u0438\u043d\u0443 \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0435", - "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 (\u0441\u0435\u043a.)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/sl.json b/homeassistant/components/tesla/translations/sl.json deleted file mode 100644 index e72538e09bc..00000000000 --- a/homeassistant/components/tesla/translations/sl.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Geslo", - "username": "E-po\u0161tni naslov" - }, - "description": "Prosimo, vnesite svoje podatke.", - "title": "Tesla - konfiguracija" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "Vsili zbujanje avtomobila ob zagonu", - "scan_interval": "Sekund med skeniranjem" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/sv.json b/homeassistant/components/tesla/translations/sv.json deleted file mode 100644 index d347634cb14..00000000000 --- a/homeassistant/components/tesla/translations/sv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "L\u00f6senord", - "username": "E-postadress" - }, - "description": "V\u00e4nligen ange din information.", - "title": "Tesla - Konfiguration" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Sekunder mellan skanningar" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/tr.json b/homeassistant/components/tesla/translations/tr.json deleted file mode 100644 index cf0d144c1ed..00000000000 --- a/homeassistant/components/tesla/translations/tr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "error": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" - }, - "step": { - "user": { - "data": { - "password": "Parola", - "username": "E-posta" - }, - "description": "L\u00fctfen bilgilerinizi giriniz." - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/uk.json b/homeassistant/components/tesla/translations/uk.json deleted file mode 100644 index 90d47ec2ff5..00000000000 --- a/homeassistant/components/tesla/translations/uk.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "config": { - "error": { - "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.", - "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." - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0410\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438" - }, - "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0434\u0430\u043d\u0456 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u043e\u0433\u043e \u0437\u0430\u043f\u0438\u0441\u0443.", - "title": "Tesla" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\u041f\u0440\u0438\u043c\u0443\u0441\u043e\u0432\u043e \u0440\u043e\u0437\u0431\u0443\u0434\u0438\u0442\u0438 \u043c\u0430\u0448\u0438\u043d\u0443 \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0443", - "scan_interval": "\u0406\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0456\u0436 \u0441\u043a\u0430\u043d\u0443\u0432\u0430\u043d\u043d\u044f\u043c\u0438 (\u0441\u0435\u043a.)" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/zh-Hant.json b/homeassistant/components/tesla/translations/zh-Hant.json deleted file mode 100644 index 9ff407efaa3..00000000000 --- a/homeassistant/components/tesla/translations/zh-Hant.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" - }, - "error": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" - }, - "step": { - "user": { - "data": { - "mfa": "MFA \u78bc\uff08\u9078\u9805\uff09", - "password": "\u5bc6\u78bc", - "username": "\u96fb\u5b50\u90f5\u4ef6" - }, - "description": "\u8acb\u8f38\u5165\u8cc7\u8a0a\u3002", - "title": "Tesla - \u8a2d\u5b9a" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "enable_wake_on_start": "\u65bc\u555f\u52d5\u6642\u5f37\u5236\u559a\u9192\u6c7d\u8eca", - "scan_interval": "\u6383\u63cf\u9593\u9694\u79d2\u6578" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 20b62832619..bbc90f7218c 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.19.0"], + "requirements": ["pyTibber==0.19.1"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/homeassistant/components/tibber/translations/fr.json b/homeassistant/components/tibber/translations/fr.json index 82dd065e53c..256516c44a6 100644 --- a/homeassistant/components/tibber/translations/fr.json +++ b/homeassistant/components/tibber/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Un compte Tibber est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/tibber/translations/hu.json b/homeassistant/components/tibber/translations/hu.json index 6ad59022845..1ff558b7280 100644 --- a/homeassistant/components/tibber/translations/hu.json +++ b/homeassistant/components/tibber/translations/hu.json @@ -13,7 +13,7 @@ "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "Add meg a hozz\u00e1f\u00e9r\u00e9si tokent a https://developer.tibber.com/settings/accesstoken c\u00edmr\u0151l", + "description": "Adja meg a hozz\u00e1f\u00e9r\u00e9si tokent a https://developer.tibber.com/settings/accesstoken c\u00edmr\u0151l", "title": "Tibber" } } diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 27446389f50..36cd16de23a 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -1,9 +1,9 @@ """Support for Tile device trackers.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging -from typing import Any, Callable +from typing import Any from pytile.tile import Tile diff --git a/homeassistant/components/tile/translations/ca.json b/homeassistant/components/tile/translations/ca.json index 60c31b8dce6..1d70a94f7af 100644 --- a/homeassistant/components/tile/translations/ca.json +++ b/homeassistant/components/tile/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" diff --git a/homeassistant/components/tile/translations/fr.json b/homeassistant/components/tile/translations/fr.json index 2af0fbab669..ade27c9053f 100644 --- a/homeassistant/components/tile/translations/fr.json +++ b/homeassistant/components/tile/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ce compte Tile est d\u00e9j\u00e0 enregistr\u00e9." + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { "invalid_auth": "Authentification invalide" diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 31b9b14c9da..e4aa6be1ff1 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -1,9 +1,9 @@ """Support for Timers.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Callable import voluptuous as vol @@ -271,7 +271,7 @@ class Timer(RestoreEntity): newduration = duration event = EVENT_TIMER_STARTED - if self._state == STATUS_ACTIVE or self._state == STATUS_PAUSED: + if self._state in (STATUS_ACTIVE, STATUS_PAUSED): event = EVENT_TIMER_RESTARTED self._state = STATUS_ACTIVE diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 8264468e2e7..3cf74a4c1f4 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -1,7 +1,9 @@ """Support for representing current time of the day as binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Callable import voluptuous as vol diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index 9983dc4bee6..30f5459c175 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -1,27 +1,25 @@ """Support for Toon binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ( - ATTR_DEFAULT_ENABLED, - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_INVERTED, - ATTR_MEASUREMENT, - ATTR_NAME, - ATTR_SECTION, - BINARY_SENSOR_ENTITIES, - DOMAIN, -) +from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator from .models import ( ToonBoilerDeviceEntity, ToonBoilerModuleDeviceEntity, ToonDisplayDeviceEntity, ToonEntity, + ToonRequiredKeysMixin, ) @@ -31,64 +29,51 @@ async def async_setup_entry( """Set up a Toon binary sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [ - ToonBoilerModuleBinarySensor( - coordinator, key="thermostat_info_boiler_connected_None" - ), - ToonDisplayBinarySensor(coordinator, key="thermostat_program_overridden"), + entities = [ + description.cls(coordinator, description) + for description in BINARY_SENSOR_ENTITIES ] - if coordinator.data.thermostat.have_opentherm_boiler: - sensors.extend( + entities.extend( [ - ToonBoilerBinarySensor(coordinator, key=key) - 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", - ) + description.cls(coordinator, description) + for description in BINARY_SENSOR_ENTITIES_BOILER ] ) - async_add_entities(sensors, True) + async_add_entities(entities, True) class ToonBinarySensor(ToonEntity, BinarySensorEntity): """Defines an Toon binary sensor.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None: + entity_description: ToonBinarySensorEntityDescription + + def __init__( + self, + coordinator: ToonDataUpdateCoordinator, + description: ToonBinarySensorEntityDescription, + ) -> None: """Initialize the Toon sensor.""" super().__init__(coordinator) - self.key = key + self.entity_description = description - sensor = BINARY_SENSOR_ENTITIES[key] - self._attr_name = sensor[ATTR_NAME] - self._attr_icon = sensor.get(ATTR_ICON) - self._attr_entity_registry_enabled_default = sensor.get( - ATTR_DEFAULT_ENABLED, True - ) - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) self._attr_unique_id = ( # This unique ID is a bit ugly and contains unneeded information. # It is here for legacy / backward compatible reasons. - f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_binary_sensor_{key}" + f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_binary_sensor_{description.key}" ) @property def is_on(self) -> bool | None: """Return the status of the binary sensor.""" - section = getattr( - self.coordinator.data, BINARY_SENSOR_ENTITIES[self.key][ATTR_SECTION] - ) - value = getattr(section, BINARY_SENSOR_ENTITIES[self.key][ATTR_MEASUREMENT]) + section = getattr(self.coordinator.data, self.entity_description.section) + value = getattr(section, self.entity_description.measurement) if value is None: return None - if BINARY_SENSOR_ENTITIES[self.key].get(ATTR_INVERTED, False): + if self.entity_description.inverted: return not value return value @@ -104,3 +89,96 @@ class ToonDisplayBinarySensor(ToonBinarySensor, ToonDisplayDeviceEntity): class ToonBoilerModuleBinarySensor(ToonBinarySensor, ToonBoilerModuleDeviceEntity): """Defines a Boiler module binary sensor.""" + + +@dataclass +class ToonBinarySensorRequiredKeysMixin(ToonRequiredKeysMixin): + """Mixin for binary sensor required keys.""" + + cls: type[ToonBinarySensor] + + +@dataclass +class ToonBinarySensorEntityDescription( + BinarySensorEntityDescription, ToonBinarySensorRequiredKeysMixin +): + """Describes Toon binary sensor entity.""" + + inverted: bool = False + + +BINARY_SENSOR_ENTITIES = ( + ToonBinarySensorEntityDescription( + key="thermostat_info_boiler_connected_None", + name="Boiler Module Connection", + section="thermostat", + measurement="boiler_module_connected", + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_registry_enabled_default=False, + cls=ToonBoilerModuleBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_program_overridden", + name="Thermostat Program Override", + section="thermostat", + measurement="program_overridden", + icon="mdi:gesture-tap", + cls=ToonDisplayBinarySensor, + ), +) + +BINARY_SENSOR_ENTITIES_BOILER: tuple[ToonBinarySensorEntityDescription, ...] = ( + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_1", + name="Boiler Heating", + section="thermostat", + measurement="heating", + icon="mdi:fire", + entity_registry_enabled_default=False, + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_2", + name="Hot Tap Water", + section="thermostat", + measurement="hot_tapwater", + icon="mdi:water-pump", + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_3", + name="Boiler Preheating", + section="thermostat", + measurement="pre_heating", + icon="mdi:fire", + entity_registry_enabled_default=False, + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_burner_info_None", + name="Boiler Burner", + section="thermostat", + measurement="burner", + icon="mdi:fire", + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_error_found_255", + name="Boiler Status", + section="thermostat", + measurement="error_found", + device_class=DEVICE_CLASS_PROBLEM, + icon="mdi:alert", + cls=ToonBoilerBinarySensor, + ), + ToonBinarySensorEntityDescription( + key="thermostat_info_ot_communication_error_0", + name="OpenTherm Connection", + section="thermostat", + measurement="opentherm_communication_error", + device_class=DEVICE_CLASS_PROBLEM, + icon="mdi:check-network-outline", + entity_registry_enabled_default=False, + cls=ToonBoilerBinarySensor, + ), +) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 678b3400b88..bf70c54e5e0 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -1,31 +1,6 @@ """Constants for the Toon integration.""" from datetime import timedelta -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_PROBLEM, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_GAS, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - TEMP_CELSIUS, - VOLUME_CUBIC_METERS, -) - DOMAIN = "toon" CONF_AGREEMENT = "agreement" @@ -41,332 +16,3 @@ CURRENCY_EUR = "EUR" VOLUME_CM3 = "CM3" VOLUME_LHOUR = "L/H" VOLUME_LMIN = "L/MIN" - -ATTR_DEFAULT_ENABLED = "default_enabled" -ATTR_INVERTED = "inverted" -ATTR_MEASUREMENT = "measurement" -ATTR_SECTION = "section" - -BINARY_SENSOR_ENTITIES = { - "thermostat_info_boiler_connected_None": { - ATTR_NAME: "Boiler Module Connection", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "boiler_module_connected", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_burner_info_1": { - ATTR_NAME: "Boiler Heating", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "heating", - ATTR_ICON: "mdi:fire", - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_burner_info_2": { - ATTR_NAME: "Hot Tap Water", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "hot_tapwater", - ATTR_ICON: "mdi:water-pump", - }, - "thermostat_info_burner_info_3": { - ATTR_NAME: "Boiler Preheating", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "pre_heating", - ATTR_ICON: "mdi:fire", - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_burner_info_None": { - ATTR_NAME: "Boiler Burner", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "burner", - ATTR_ICON: "mdi:fire", - }, - "thermostat_info_error_found_255": { - ATTR_NAME: "Boiler Status", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "error_found", - ATTR_DEVICE_CLASS: DEVICE_CLASS_PROBLEM, - ATTR_ICON: "mdi:alert", - }, - "thermostat_info_ot_communication_error_0": { - ATTR_NAME: "OpenTherm Connection", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "opentherm_communication_error", - ATTR_DEVICE_CLASS: DEVICE_CLASS_PROBLEM, - ATTR_ICON: "mdi:check-network-outline", - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_program_overridden": { - ATTR_NAME: "Thermostat Program Override", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "program_overridden", - ATTR_ICON: "mdi:gesture-tap", - }, -} - -SENSOR_ENTITIES = { - "current_display_temperature": { - ATTR_NAME: "Temperature", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "current_display_temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "gas_average": { - ATTR_NAME: "Average Gas Usage", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CM3, - ATTR_ICON: "mdi:gas-cylinder", - }, - "gas_average_daily": { - ATTR_NAME: "Average Daily Gas Usage", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "day_average", - ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_DEFAULT_ENABLED: False, - }, - "gas_daily_usage": { - ATTR_NAME: "Gas Usage Today", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "day_usage", - ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - }, - "gas_daily_cost": { - ATTR_NAME: "Gas Cost Today", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "day_cost", - ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, - ATTR_ICON: "mdi:gas-cylinder", - }, - "gas_meter_reading": { - ATTR_NAME: "Gas Meter", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, - ATTR_DEFAULT_ENABLED: False, - }, - "gas_value": { - ATTR_NAME: "Current Gas Usage", - ATTR_SECTION: "gas_usage", - ATTR_MEASUREMENT: "current", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CM3, - ATTR_ICON: "mdi:gas-cylinder", - }, - "power_average": { - ATTR_NAME: "Average Power Usage", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "average", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_DEFAULT_ENABLED: False, - }, - "power_average_daily": { - ATTR_NAME: "Average Daily Energy Usage", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_DEFAULT_ENABLED: False, - }, - "power_daily_cost": { - ATTR_NAME: "Energy Cost Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_cost", - ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, - ATTR_ICON: "mdi:power-plug", - }, - "power_daily_value": { - ATTR_NAME: "Energy Usage Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - "power_meter_reading": { - ATTR_NAME: "Electricity Meter Feed IN Tariff 1", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_high", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "power_meter_reading_low": { - ATTR_NAME: "Electricity Meter Feed IN Tariff 2", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_low", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "power_value": { - ATTR_NAME: "Current Power Usage", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "solar_meter_reading_produced": { - ATTR_NAME: "Electricity Meter Feed OUT Tariff 1", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_produced_high", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "solar_meter_reading_low_produced": { - ATTR_NAME: "Electricity Meter Feed OUT Tariff 2", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_produced_low", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - ATTR_DEFAULT_ENABLED: False, - }, - "solar_value": { - ATTR_NAME: "Current Solar Power Production", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current_solar", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "solar_maximum": { - ATTR_NAME: "Max Solar Power Production Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_max_solar", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, - "solar_produced": { - ATTR_NAME: "Solar Power Production to Grid", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current_produced", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: ATTR_MEASUREMENT, - }, - "power_usage_day_produced_solar": { - ATTR_NAME: "Solar Energy Produced Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_produced_solar", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - "power_usage_day_to_grid_usage": { - ATTR_NAME: "Energy Produced To Grid Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_to_grid_usage", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_DEFAULT_ENABLED: False, - }, - "power_usage_day_from_grid_usage": { - ATTR_NAME: "Energy Usage From Grid Today", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "day_from_grid_usage", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_DEFAULT_ENABLED: False, - }, - "solar_average_produced": { - ATTR_NAME: "Average Solar Power Production to Grid", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "average_produced", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_DEFAULT_ENABLED: False, - }, - "thermostat_info_current_modulation_level": { - ATTR_NAME: "Boiler Modulation Level", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "current_modulation_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:percent", - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "power_usage_current_covered_by_solar": { - ATTR_NAME: "Current Power Usage Covered By Solar", - ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "current_covered_by_solar", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_ICON: "mdi:solar-power", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "water_average": { - ATTR_NAME: "Average Water Usage", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - }, - "water_average_daily": { - ATTR_NAME: "Average Daily Water Usage", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - }, - "water_daily_usage": { - ATTR_NAME: "Water Usage Today", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - }, - "water_meter_reading": { - ATTR_NAME: "Water Meter", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_ICON: "mdi:water", - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - }, - "water_value": { - ATTR_NAME: "Current Water Usage", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "current", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, - ATTR_ICON: "mdi:water-pump", - ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "water_daily_cost": { - ATTR_NAME: "Water Cost Today", - ATTR_SECTION: "water_usage", - ATTR_MEASUREMENT: "day_cost", - ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, - ATTR_ICON: "mdi:water-pump", - ATTR_DEFAULT_ENABLED: False, - }, -} - -SWITCH_ENTITIES = { - "thermostat_holiday_mode": { - ATTR_NAME: "Holiday Mode", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "holiday_mode", - ATTR_ICON: "mdi:airport", - }, - "thermostat_program": { - ATTR_NAME: "Thermostat Program", - ATTR_SECTION: "thermostat", - ATTR_MEASUREMENT: "program", - ATTR_ICON: "mdi:calendar-clock", - }, -} diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 2df5cfa2e90..6a5d52d393b 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -3,7 +3,7 @@ "name": "Toon", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/toon", - "requirements": ["toonapi==0.2.0"], + "requirements": ["toonapi==0.2.1"], "dependencies": ["http"], "after_dependencies": ["cloud"], "codeowners": ["@frenck"], diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 7fb45af4d53..a95a8f622a8 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -1,6 +1,8 @@ """DataUpdate Coordinator, and base Entity and Device models for Toon.""" from __future__ import annotations +from dataclasses import dataclass + from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -115,3 +117,11 @@ class ToonBoilerDeviceEntity(ToonEntity): "identifiers": {(DOMAIN, agreement_id, "boiler")}, "via_device": (DOMAIN, agreement_id, "boiler_module"), } + + +@dataclass +class ToonRequiredKeysMixin: + """Mixin for required keys.""" + + section: str + measurement: str diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 4522e34943c..cf7546c3fa6 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -1,21 +1,29 @@ """Support for Toon sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant -from .const import ( - ATTR_DEFAULT_ENABLED, - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_MEASUREMENT, - ATTR_NAME, - ATTR_SECTION, - ATTR_UNIT_OF_MEASUREMENT, - DOMAIN, - SENSOR_ENTITIES, -) +from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN from .coordinator import ToonDataUpdateCoordinator from .models import ( ToonBoilerDeviceEntity, @@ -23,6 +31,7 @@ from .models import ( ToonElectricityMeterDeviceEntity, ToonEntity, ToonGasMeterDeviceEntity, + ToonRequiredKeysMixin, ToonSolarDeviceEntity, ToonWaterMeterDeviceEntity, ) @@ -34,112 +43,54 @@ async def async_setup_entry( """Set up Toon sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [ - ToonElectricityMeterDeviceSensor(coordinator, key=key) - for key in ( - "power_average_daily", - "power_average", - "power_daily_cost", - "power_daily_value", - "power_meter_reading_low", - "power_meter_reading", - "power_value", - "solar_meter_reading_low_produced", - "solar_meter_reading_produced", - ) + entities = [ + description.cls(coordinator, description) for description in SENSOR_ENTITIES ] - sensors.extend( - [ToonDisplayDeviceSensor(coordinator, key="current_display_temperature")] - ) - - sensors.extend( - [ - ToonGasMeterDeviceSensor(coordinator, key=key) - for key in ( - "gas_average_daily", - "gas_average", - "gas_daily_cost", - "gas_daily_usage", - "gas_meter_reading", - "gas_value", - ) - ] - ) - - sensors.extend( - [ - ToonWaterMeterDeviceSensor(coordinator, key=key) - for key in ( - "water_average_daily", - "water_average", - "water_daily_cost", - "water_daily_usage", - "water_meter_reading", - "water_value", - ) - ] - ) - if coordinator.data.agreement.is_toon_solar: - sensors.extend( + entities.extend( [ - ToonSolarDeviceSensor(coordinator, key=key) - for key in ( - "solar_value", - "solar_maximum", - "solar_produced", - "solar_average_produced", - "power_usage_day_produced_solar", - "power_usage_day_from_grid_usage", - "power_usage_day_to_grid_usage", - "power_usage_current_covered_by_solar", - ) + description.cls(coordinator, description) + for description in SENSOR_ENTITIES_SOLAR ] ) if coordinator.data.thermostat.have_opentherm_boiler: - sensors.extend( + entities.extend( [ - ToonBoilerDeviceSensor( - coordinator, key="thermostat_info_current_modulation_level" - ) + description.cls(coordinator, description) + for description in SENSOR_ENTITIES_BOILER ] ) - async_add_entities(sensors, True) + async_add_entities(entities, True) class ToonSensor(ToonEntity, SensorEntity): """Defines a Toon sensor.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None: + entity_description: ToonSensorEntityDescription + + def __init__( + self, + coordinator: ToonDataUpdateCoordinator, + description: ToonSensorEntityDescription, + ) -> None: """Initialize the Toon sensor.""" - self.key = key + self.entity_description = description super().__init__(coordinator) - sensor = SENSOR_ENTITIES[key] - self._attr_entity_registry_enabled_default = sensor.get( - ATTR_DEFAULT_ENABLED, True - ) - self._attr_icon = sensor.get(ATTR_ICON) - self._attr_name = sensor[ATTR_NAME] - self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_native_unit_of_measurement = sensor[ATTR_UNIT_OF_MEASUREMENT] - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) self._attr_unique_id = ( # This unique ID is a bit ugly and contains unneeded information. # It is here for legacy / backward compatible reasons. - f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_sensor_{key}" + f"{DOMAIN}_{coordinator.data.agreement.agreement_id}_sensor_{description.key}" ) @property def native_value(self) -> str | None: """Return the state of the sensor.""" - section = getattr( - self.coordinator.data, SENSOR_ENTITIES[self.key][ATTR_SECTION] - ) - return getattr(section, SENSOR_ENTITIES[self.key][ATTR_MEASUREMENT]) + section = getattr(self.coordinator.data, self.entity_description.section) + return getattr(section, self.entity_description.measurement) class ToonElectricityMeterDeviceSensor(ToonSensor, ToonElectricityMeterDeviceEntity): @@ -164,3 +115,336 @@ class ToonBoilerDeviceSensor(ToonSensor, ToonBoilerDeviceEntity): class ToonDisplayDeviceSensor(ToonSensor, ToonDisplayDeviceEntity): """Defines a Display sensor.""" + + +@dataclass +class ToonSensorRequiredKeysMixin(ToonRequiredKeysMixin): + """Mixin for sensor required keys.""" + + cls: type[ToonSensor] + + +@dataclass +class ToonSensorEntityDescription(SensorEntityDescription, ToonSensorRequiredKeysMixin): + """Describes Toon sensor entity.""" + + +SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( + ToonSensorEntityDescription( + key="current_display_temperature", + name="Temperature", + section="thermostat", + measurement="current_display_temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonDisplayDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_average", + name="Average Gas Usage", + section="gas_usage", + measurement="average", + native_unit_of_measurement=VOLUME_CM3, + icon="mdi:gas-cylinder", + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_average_daily", + name="Average Daily Gas Usage", + section="gas_usage", + measurement="day_average", + device_class=DEVICE_CLASS_GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + entity_registry_enabled_default=False, + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_daily_usage", + name="Gas Usage Today", + section="gas_usage", + measurement="day_usage", + device_class=DEVICE_CLASS_GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_daily_cost", + name="Gas Cost Today", + section="gas_usage", + measurement="day_cost", + native_unit_of_measurement=CURRENCY_EUR, + icon="mdi:gas-cylinder", + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_meter_reading", + name="Gas Meter", + section="gas_usage", + measurement="meter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_GAS, + entity_registry_enabled_default=False, + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="gas_value", + name="Current Gas Usage", + section="gas_usage", + measurement="current", + native_unit_of_measurement=VOLUME_CM3, + icon="mdi:gas-cylinder", + cls=ToonGasMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_average", + name="Average Power Usage", + section="power_usage", + measurement="average", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_average_daily", + name="Average Daily Energy Usage", + section="power_usage", + measurement="day_average", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_daily_cost", + name="Energy Cost Today", + section="power_usage", + measurement="day_cost", + native_unit_of_measurement=CURRENCY_EUR, + icon="mdi:power-plug", + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_daily_value", + name="Energy Usage Today", + section="power_usage", + measurement="day_usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_meter_reading", + name="Electricity Meter Feed IN Tariff 1", + section="power_usage", + measurement="meter_high", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_meter_reading_low", + name="Electricity Meter Feed IN Tariff 2", + section="power_usage", + measurement="meter_low", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_value", + name="Current Power Usage", + section="power_usage", + measurement="current", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_meter_reading_produced", + name="Electricity Meter Feed OUT Tariff 1", + section="power_usage", + measurement="meter_produced_high", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_meter_reading_low_produced", + name="Electricity Meter Feed OUT Tariff 2", + section="power_usage", + measurement="meter_produced_low", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + cls=ToonElectricityMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_average", + name="Average Water Usage", + section="water_usage", + measurement="average", + native_unit_of_measurement=VOLUME_LMIN, + icon="mdi:water", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_average_daily", + name="Average Daily Water Usage", + section="water_usage", + measurement="day_average", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + icon="mdi:water", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_daily_usage", + name="Water Usage Today", + section="water_usage", + measurement="day_usage", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + icon="mdi:water", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_meter_reading", + name="Water Meter", + section="water_usage", + measurement="meter", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + icon="mdi:water", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_TOTAL_INCREASING, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_value", + name="Current Water Usage", + section="water_usage", + measurement="current", + native_unit_of_measurement=VOLUME_LMIN, + icon="mdi:water-pump", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonWaterMeterDeviceSensor, + ), + ToonSensorEntityDescription( + key="water_daily_cost", + name="Water Cost Today", + section="water_usage", + measurement="day_cost", + native_unit_of_measurement=CURRENCY_EUR, + icon="mdi:water-pump", + entity_registry_enabled_default=False, + cls=ToonWaterMeterDeviceSensor, + ), +) + +SENSOR_ENTITIES_SOLAR: tuple[ToonSensorEntityDescription, ...] = ( + ToonSensorEntityDescription( + key="solar_value", + name="Current Solar Power Production", + section="power_usage", + measurement="current_solar", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_maximum", + name="Max Solar Power Production Today", + section="power_usage", + measurement="day_max_solar", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_produced", + name="Solar Power Production to Grid", + section="power_usage", + measurement="current_produced", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_day_produced_solar", + name="Solar Energy Produced Today", + section="power_usage", + measurement="day_produced_solar", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_day_to_grid_usage", + name="Energy Produced To Grid Today", + section="power_usage", + measurement="day_to_grid_usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + entity_registry_enabled_default=False, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_day_from_grid_usage", + name="Energy Usage From Grid Today", + section="power_usage", + measurement="day_from_grid_usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + entity_registry_enabled_default=False, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="solar_average_produced", + name="Average Solar Power Production to Grid", + section="power_usage", + measurement="average_produced", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + entity_registry_enabled_default=False, + cls=ToonSolarDeviceSensor, + ), + ToonSensorEntityDescription( + key="power_usage_current_covered_by_solar", + name="Current Power Usage Covered By Solar", + section="power_usage", + measurement="current_covered_by_solar", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:solar-power", + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonSolarDeviceSensor, + ), +) + +SENSOR_ENTITIES_BOILER: tuple[ToonSensorEntityDescription, ...] = ( + ToonSensorEntityDescription( + key="thermostat_info_current_modulation_level", + name="Boiler Modulation Level", + section="thermostat", + measurement="current_modulation_level", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonBoilerDeviceSensor, + ), +) diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index 06ca9c6631b..de68b35befd 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -1,4 +1,7 @@ """Support for Toon switches.""" +from __future__ import annotations + +from dataclasses import dataclass from typing import Any from toonapi import ( @@ -8,21 +11,14 @@ from toonapi import ( PROGRAM_STATE_ON, ) -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ( - ATTR_ICON, - ATTR_MEASUREMENT, - ATTR_NAME, - ATTR_SECTION, - DOMAIN, - SWITCH_ENTITIES, -) +from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator from .helpers import toon_exception_handler -from .models import ToonDisplayDeviceEntity, ToonEntity +from .models import ToonDisplayDeviceEntity, ToonEntity, ToonRequiredKeysMixin async def async_setup_entry( @@ -32,39 +28,38 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ToonProgramSwitch(coordinator), ToonHolidayModeSwitch(coordinator)] + [description.cls(coordinator, description) for description in SWITCH_ENTITIES] ) class ToonSwitch(ToonEntity, SwitchEntity): """Defines an Toon switch.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator, *, key: str) -> None: + entity_description: ToonSwitchEntityDescription + + def __init__( + self, + coordinator: ToonDataUpdateCoordinator, + description: ToonSwitchEntityDescription, + ) -> None: """Initialize the Toon switch.""" - self.key = key + self.entity_description = description super().__init__(coordinator) - switch = SWITCH_ENTITIES[key] - self._attr_icon = switch[ATTR_ICON] - self._attr_name = switch[ATTR_NAME] - self._attr_unique_id = f"{coordinator.data.agreement.agreement_id}_{key}" + self._attr_unique_id = ( + f"{coordinator.data.agreement.agreement_id}_{description.key}" + ) @property def is_on(self) -> bool: """Return the status of the binary sensor.""" - section = getattr( - self.coordinator.data, SWITCH_ENTITIES[self.key][ATTR_SECTION] - ) - return getattr(section, SWITCH_ENTITIES[self.key][ATTR_MEASUREMENT]) + section = getattr(self.coordinator.data, self.entity_description.section) + return getattr(section, self.entity_description.measurement) class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): """Defines a Toon program switch.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator) -> None: - """Initialize the Toon program switch.""" - super().__init__(coordinator, key="thermostat_program") - @toon_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Toon program switch.""" @@ -83,10 +78,6 @@ class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): """Defines a Toon Holiday mode switch.""" - def __init__(self, coordinator: ToonDataUpdateCoordinator) -> None: - """Initialize the Toon holiday switch.""" - super().__init__(coordinator, key="thermostat_holiday_mode") - @toon_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Toon holiday mode switch.""" @@ -100,3 +91,35 @@ class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): await self.coordinator.toon.set_active_state( ACTIVE_STATE_HOLIDAY, PROGRAM_STATE_OFF ) + + +@dataclass +class ToonSwitchRequiredKeysMixin(ToonRequiredKeysMixin): + """Mixin for switch required keys.""" + + cls: type[ToonSwitch] + + +@dataclass +class ToonSwitchEntityDescription(SwitchEntityDescription, ToonSwitchRequiredKeysMixin): + """Describes Toon switch entity.""" + + +SWITCH_ENTITIES: tuple[ToonSwitchEntityDescription, ...] = ( + ToonSwitchEntityDescription( + key="thermostat_holiday_mode", + name="Holiday Mode", + section="thermostat", + measurement="holiday_mode", + icon="mdi:airport", + cls=ToonHolidayModeSwitch, + ), + ToonSwitchEntityDescription( + key="thermostat_program", + name="Thermostat Program", + section="thermostat", + measurement="program", + icon="mdi:calendar-clock", + cls=ToonProgramSwitch, + ), +) diff --git a/homeassistant/components/toon/translations/fr.json b/homeassistant/components/toon/translations/fr.json index 3aa36d8a554..2b70c85dc8f 100644 --- a/homeassistant/components/toon/translations/fr.json +++ b/homeassistant/components/toon/translations/fr.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "L'accord s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9.", - "authorize_url_timeout": "Timout de g\u00e9n\u00e9ration de l'URL d'autorisation.", - "missing_configuration": "The composant n'est pas configur\u00e9. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation.", + "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.", "no_agreements": "Ce compte n'a pas d'affichages Toon.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )", "unknown_authorize_url_generation": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation." diff --git a/homeassistant/components/toon/translations/he.json b/homeassistant/components/toon/translations/he.json index 431a7b32509..c4269a46b1c 100644 --- a/homeassistant/components/toon/translations/he.json +++ b/homeassistant/components/toon/translations/he.json @@ -3,7 +3,8 @@ "abort": { "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})" + "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})", + "unknown_authorize_url_generation": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05e9\u05dc \u05d4\u05e8\u05e9\u05d0\u05d4." } } } \ No newline at end of file diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json index 18f333dccdf..b1a69144dd5 100644 --- a/homeassistant/components/toon/translations/hu.json +++ b/homeassistant/components/toon/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A kiv\u00e1lasztott meg\u00e1llapod\u00e1s m\u00e1r konfigur\u00e1lva van.", "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\u00e9rem, k\u00f6vesse 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." @@ -17,7 +17,7 @@ "title": "V\u00e1lassza ki a meg\u00e1llapod\u00e1st" }, "pick_implementation": { - "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9shez" + "title": "V\u00e1lassza ki a b\u00e9rl\u0151t a hiteles\u00edt\u00e9shez" } } } diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index cbe1d4e449c..fa42c81e1be 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/es.json b/homeassistant/components/totalconnect/translations/es.json index c4923884c43..63d61445ef5 100644 --- a/homeassistant/components/totalconnect/translations/es.json +++ b/homeassistant/components/totalconnect/translations/es.json @@ -14,7 +14,7 @@ "location": "Localizaci\u00f3n", "usercode": "Codigo de usuario" }, - "description": "Ingrese el c\u00f3digo de usuario para este usuario en esta ubicaci\u00f3n", + "description": "Introduce el c\u00f3digo de usuario para este usuario en la ubicaci\u00f3n {location_id}", "title": "C\u00f3digos de usuario de ubicaci\u00f3n" }, "reauth_confirm": { diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index 668b20726fc..6c51c724f77 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index 319611fd2b1..9b55278e9a8 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -14,7 +14,7 @@ "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}", + "description": "Adja meg ennek a felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1t a k\u00f6vetkez\u0151 helyen: {location_id}", "title": "Helyhaszn\u00e1lati k\u00f3dok" }, "reauth_confirm": { diff --git a/homeassistant/components/totalconnect/translations/id.json b/homeassistant/components/totalconnect/translations/id.json index c1bdf664994..b1bc5573021 100644 --- a/homeassistant/components/totalconnect/translations/id.json +++ b/homeassistant/components/totalconnect/translations/id.json @@ -13,7 +13,7 @@ "data": { "location": "Lokasi" }, - "description": "Masukkan kode pengguna untuk pengguna ini di lokasi ini", + "description": "Masukkan kode pengguna untuk pengguna ini di lokasi {location_id}", "title": "Lokasi Kode Pengguna" }, "reauth_confirm": { diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index 3193b6f847c..9cd80428080 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -1,4 +1,6 @@ """Platform for Roth Touchline floor heating controller.""" +from typing import NamedTuple + from pytouchline import PyTouchline import voluptuous as vol @@ -11,17 +13,25 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv + +class PresetMode(NamedTuple): + """Settings for preset mode.""" + + mode: int + program: int + + PRESET_MODES = { - "Normal": {"mode": 0, "program": 0}, - "Night": {"mode": 1, "program": 0}, - "Holiday": {"mode": 2, "program": 0}, - "Pro 1": {"mode": 0, "program": 1}, - "Pro 2": {"mode": 0, "program": 2}, - "Pro 3": {"mode": 0, "program": 3}, + "Normal": PresetMode(mode=0, program=0), + "Night": PresetMode(mode=1, program=0), + "Holiday": PresetMode(mode=2, program=0), + "Pro 1": PresetMode(mode=0, program=1), + "Pro 2": PresetMode(mode=0, program=2), + "Pro 3": PresetMode(mode=0, program=3), } TOUCHLINE_HA_PRESETS = { - (settings["mode"], settings["program"]): preset + (settings.mode, settings.program): preset for preset, settings in PRESET_MODES.items() } @@ -119,8 +129,9 @@ class Touchline(ClimateEntity): def set_preset_mode(self, preset_mode): """Set new target preset mode.""" - self.unit.set_operation_mode(PRESET_MODES[preset_mode]["mode"]) - self.unit.set_week_program(PRESET_MODES[preset_mode]["program"]) + preset_mode = PRESET_MODES[preset_mode] + self.unit.set_operation_mode(preset_mode.mode) + self.unit.set_week_program(preset_mode.program) def set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index aad934b2600..5c40f61e2df 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,198 +1,146 @@ """Component to embed TP-Link smart home devices.""" from __future__ import annotations -from datetime import timedelta -import logging -import time +import asyncio from typing import Any -from pyHS100.smartdevice import SmartDevice, SmartDeviceException -from pyHS100.smartplug import SmartPlug +from kasa import SmartDevice, SmartDeviceException +from kasa.discover import Discover import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH -from homeassistant.config_entries import ConfigEntry -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.components import network +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -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, - COORDINATORS, + DOMAIN, PLATFORMS, - UNAVAILABLE_DEVICES, - UNAVAILABLE_RETRY_DELAY, ) - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "tplink" +from .coordinator import TPLinkDataUpdateCoordinator +from .migration import ( + async_migrate_entities_devices, + async_migrate_legacy_entries, + async_migrate_yaml_entries, +) TPLINK_HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string}) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_LIGHT, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_SWITCH, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_STRIP, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_DIMMER, default=[]): vol.All( - cv.ensure_list, [TPLINK_HOST_SCHEMA] - ), - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LIGHT, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_SWITCH, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_STRIP, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_DIMMER, default=[]): vol.All( + cv.ensure_list, [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) +@callback +def async_trigger_discovery( + hass: HomeAssistant, + discovered_devices: dict[str, SmartDevice], +) -> None: + """Trigger config flows for discovered devices.""" + for formatted_mac, device in discovered_devices.items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_NAME: device.alias, + CONF_HOST: device.host, + CONF_MAC: formatted_mac, + }, + ) + ) + + +async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: + """Discover TPLink devices on configured network interfaces.""" + broadcast_addresses = await network.async_get_ipv4_broadcast_addresses(hass) + tasks = [Discover.discover(target=str(address)) for address in broadcast_addresses] + discovered_devices: dict[str, SmartDevice] = {} + for device_list in await asyncio.gather(*tasks): + for device in device_list.values(): + discovered_devices[dr.format_mac(device.mac)] = device + return discovered_devices + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" conf = config.get(DOMAIN) - hass.data[DOMAIN] = {} - hass.data[DOMAIN][ATTR_CONFIG] = conf + legacy_entry = None + config_entries_by_mac = {} + for entry in hass.config_entries.async_entries(DOMAIN): + if async_entry_is_legacy(entry): + legacy_entry = entry + elif entry.unique_id: + config_entries_by_mac[entry.unique_id] = entry + + discovered_devices = await async_discover_devices(hass) + hosts_by_mac = {mac: device.host for mac, device in discovered_devices.items()} + + if legacy_entry: + async_migrate_legacy_entries( + hass, hosts_by_mac, config_entries_by_mac, legacy_entry + ) if conf is not None: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) + async_migrate_yaml_entries(hass, conf) + + if discovered_devices: + async_trigger_discovery(hass, discovered_devices) return True 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) + if async_entry_is_legacy(entry): + return True - 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 - 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() - if config_data is not None: - static_devices = get_static_devices(config_data) - - lights.extend(static_devices.lights) - switches.extend(static_devices.switches) - - # Add discovered devices - if config_data is None or config_data[CONF_DISCOVERY]: - discovered_devices = await async_discover_devices( - hass, static_devices, device_count - ) - - lights.extend(discovered_devices.lights) - switches.extend(discovered_devices.switches) - - if lights: - _LOGGER.debug( - "Got %s lights: %s", len(lights), ", ".join(d.host for d in lights) - ) - - if switches: - _LOGGER.debug( - "Got %s switches: %s", - len(switches), - ", ".join(d.host for d in switches), - ) - - 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: - await hass.async_add_executor_job(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) + legacy_entry: ConfigEntry | None = None + for config_entry in hass.config_entries.async_entries(DOMAIN): + if async_entry_is_legacy(config_entry): + legacy_entry = config_entry break - # prepare DataUpdateCoordinators - hass_data[COORDINATORS] = {} - for switch in switches: + if legacy_entry is not None: + await async_migrate_entities_devices(hass, legacy_entry.entry_id, entry) - try: - info = 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, info["alias"]) - 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 - ] + try: + device: SmartDevice = await Discover.discover_single(entry.data[CONF_HOST]) + except SmartDeviceException as ex: + raise ConfigEntryNotReady from ex + hass.data[DOMAIN][entry.entry_id] = TPLinkDataUpdateCoordinator(hass, device) hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -200,81 +148,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) hass_data: dict[str, Any] = hass.data[DOMAIN] - if unload_ok: - hass_data.clear() - + if entry.entry_id not in hass_data: + return True + device: SmartDevice = hass.data[DOMAIN][entry.entry_id].device + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass_data.pop(entry.entry_id) + await device.protocol.close() return unload_ok -class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): - """DataUpdateCoordinator to gather data for specific SmartPlug.""" +@callback +def async_entry_is_legacy(entry: ConfigEntry) -> bool: + """Check if a config entry is the legacy shared one.""" + return entry.unique_id is None or entry.unique_id == DOMAIN - def __init__( - self, - hass: HomeAssistant, - smartplug: SmartPlug, - alias: str, - ) -> None: - """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" - self.smartplug = smartplug - update_interval = timedelta(seconds=30) - super().__init__( - hass, - _LOGGER, - name=alias, - update_interval=update_interval, - ) - - def _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] = bool(info["relay_state"]) - 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 - - # Check if the device has emeter - if "ENE" in info["feature"]: - 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), - } - emeter_statics = self.smartplug.get_emeter_daily() - 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 - - async def _async_update_data(self) -> dict: - """Fetch all device and sensor data from api.""" - return await self.hass.async_add_executor_job(self._update_data) +def legacy_device_id(device: SmartDevice) -> str: + """Convert the device id so it matches what was used in the original version.""" + device_id: str = device.device_id + # Plugs are prefixed with the mac in python-kasa but not + # in pyHS100 so we need to strip off the mac + if "_" not in device_id: + return device_id + return device_id.split("_")[1] diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py deleted file mode 100644 index 6f6fb0a14c2..00000000000 --- a/homeassistant/components/tplink/common.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Common code for tplink.""" -from __future__ import annotations - -import logging -from typing import Callable - -from pyHS100 import ( - Discover, - SmartBulb, - SmartDevice, - SmartDeviceException, - SmartPlug, - SmartStrip, -) - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity - -from .const import ( - CONF_DIMMER, - CONF_LIGHT, - CONF_STRIP, - CONF_SWITCH, - DOMAIN as TPLINK_DOMAIN, - MAX_DISCOVERY_RETRIES, -) - -_LOGGER = logging.getLogger(__name__) - - -class SmartDevices: - """Hold different kinds of devices.""" - - def __init__( - self, lights: list[SmartDevice] = None, switches: list[SmartDevice] = None - ) -> None: - """Initialize device holder.""" - self._lights = lights or [] - self._switches = switches or [] - - @property - def lights(self) -> list[SmartDevice]: - """Get the lights.""" - return self._lights - - @property - def switches(self) -> list[SmartDevice]: - """Get the switches.""" - return self._switches - - def has_device_with_host(self, host: str) -> bool: - """Check if a devices exists with a specific host.""" - for device in self.lights + self.switches: - if device.host == host: - return True - - return False - - -async def async_get_discoverable_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: - """Return if there are devices that can be discovered.""" - - def discover() -> dict[str, SmartDevice]: - return Discover.discover() - - return await hass.async_add_executor_job(discover) - - -async def async_discover_devices( - hass: HomeAssistant, existing_devices: SmartDevices, target_device_count: int -) -> SmartDevices: - """Get devices through discovery.""" - - lights = [] - switches = [] - - def process_devices() -> None: - for dev in devices.values(): - # If this device already exists, ignore dynamic setup. - if existing_devices.has_device_with_host(dev.host): - continue - - if isinstance(dev, SmartStrip): - for plug in dev.plugs.values(): - switches.append(plug) - elif isinstance(dev, SmartPlug): - try: - if dev.is_dimmable: # Dimmers act as lights - lights.append(dev) - else: - switches.append(dev) - except SmartDeviceException as ex: - _LOGGER.error("Unable to connect to device %s: %s", dev.host, ex) - - elif isinstance(dev, SmartBulb): - lights.append(dev) - else: - _LOGGER.error("Unknown smart device type: %s", type(dev)) - - devices: dict[str, SmartDevice] = {} - for attempt in range(1, MAX_DISCOVERY_RETRIES + 1): - _LOGGER.debug( - "Discovering tplink devices, attempt %s of %s", - attempt, - MAX_DISCOVERY_RETRIES, - ) - discovered_devices = await async_get_discoverable_devices(hass) - _LOGGER.info( - "Discovered %s TP-Link of expected %s smart home device(s)", - len(discovered_devices), - target_device_count, - ) - for device_ip in discovered_devices: - devices[device_ip] = discovered_devices[device_ip] - - if len(discovered_devices) >= target_device_count: - _LOGGER.info( - "Discovered at least as many devices on the network as exist in our device registry, no need to retry" - ) - break - - _LOGGER.info( - "Found %s unique TP-Link smart home device(s) after %s discovery attempts", - len(devices), - attempt, - ) - await hass.async_add_executor_job(process_devices) - - return SmartDevices(lights, switches) - - -def get_static_devices(config_data) -> SmartDevices: - """Get statically defined devices in the config.""" - _LOGGER.debug("Getting static devices") - lights = [] - switches = [] - - for type_ in (CONF_LIGHT, CONF_SWITCH, CONF_STRIP, CONF_DIMMER): - for entry in config_data[type_]: - host = entry["host"] - try: - if type_ == CONF_LIGHT: - lights.append(SmartBulb(host)) - elif type_ == CONF_SWITCH: - switches.append(SmartPlug(host)) - elif type_ == CONF_STRIP: - for plug in SmartStrip(host).plugs.values(): - switches.append(plug) - # Dimmers need to be defined as smart plugs to work correctly. - elif type_ == CONF_DIMMER: - lights.append(SmartPlug(host)) - except SmartDeviceException as sde: - _LOGGER.error( - "Failed to setup device %s due to %s; not retrying", host, sde - ) - return SmartDevices(lights, switches) - - -def add_available_devices( - hass: HomeAssistant, device_type: str, device_class: Callable -) -> list[Entity]: - """Get sysinfo for all devices.""" - - devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][device_type] - - if f"{device_type}_remaining" in hass.data[TPLINK_DOMAIN]: - devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][ - f"{device_type}_remaining" - ] - - entities_ready: list[Entity] = [] - devices_unavailable: list[SmartDevice] = [] - for device in devices: - try: - device.get_sysinfo() - entities_ready.append(device_class(device)) - except SmartDeviceException as ex: - devices_unavailable.append(device) - _LOGGER.warning( - "Unable to communicate with device %s: %s", - device.host, - ex, - ) - - hass.data[TPLINK_DOMAIN][f"{device_type}_remaining"] = devices_unavailable - return entities_ready diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 033d80cf407..d9bed10ee42 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -1,11 +1,178 @@ """Config flow for TP-Link.""" -from homeassistant.helpers import config_entry_flow +from __future__ import annotations -from .common import async_get_discoverable_devices +import logging +from typing import Any + +from kasa import SmartDevice, SmartDeviceException +from kasa.discover import Discover +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import async_discover_devices, async_entry_is_legacy from .const import DOMAIN -config_entry_flow.register_discovery_flow( - DOMAIN, - "TP-Link Smart Home", - async_get_discoverable_devices, -) +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for tplink.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, SmartDevice] = {} + self._discovered_device: SmartDevice | None = None + + async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle discovery via dhcp.""" + return await self._async_handle_discovery( + discovery_info[IP_ADDRESS], discovery_info[MAC_ADDRESS] + ) + + async def async_step_discovery( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle discovery.""" + return await self._async_handle_discovery( + discovery_info[CONF_HOST], discovery_info[CONF_MAC] + ) + + async def _async_handle_discovery(self, host: str, mac: str) -> FlowResult: + """Handle any discovery.""" + await self.async_set_unique_id(dr.format_mac(mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._async_abort_entries_match({CONF_HOST: host}) + self.context[CONF_HOST] = host + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == host: + return self.async_abort(reason="already_in_progress") + + try: + self._discovered_device = await self._async_try_connect( + host, raise_on_progress=True + ) + except SmartDeviceException: + return self.async_abort(reason="cannot_connect") + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + if user_input is not None: + return self._async_create_entry_from_device(self._discovered_device) + + self._set_confirm_only() + placeholders = { + "name": self._discovered_device.alias, + "model": self._discovered_device.model, + "host": self._discovered_device.host, + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_confirm", description_placeholders=placeholders + ) + + 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: + host = user_input[CONF_HOST] + if not host: + return await self.async_step_pick_device() + try: + device = await self._async_try_connect(host, raise_on_progress=False) + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + return self._async_create_entry_from_device(device) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Optional(CONF_HOST, default=""): str}), + errors=errors, + ) + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the step to pick discovered device.""" + if user_input is not None: + mac = user_input[CONF_DEVICE] + await self.async_set_unique_id(mac, raise_on_progress=False) + return self._async_create_entry_from_device(self._discovered_devices[mac]) + + configured_devices = { + entry.unique_id + for entry in self._async_current_entries() + if not async_entry_is_legacy(entry) + } + self._discovered_devices = await async_discover_devices(self.hass) + devices_name = { + formatted_mac: f"{device.alias} {device.model} ({device.host}) {formatted_mac}" + for formatted_mac, device in self._discovered_devices.items() + if formatted_mac not in configured_devices + } + # Check if there is at least one device + if not devices_name: + return self.async_abort(reason="no_devices_found") + return self.async_show_form( + step_id="pick_device", + data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), + ) + + async def async_step_migration(self, migration_input: dict[str, Any]) -> FlowResult: + """Handle migration from legacy config entry to per device config entry.""" + mac = migration_input[CONF_MAC] + await self.async_set_unique_id(dr.format_mac(mac), raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=migration_input[CONF_NAME], + data={ + CONF_HOST: migration_input[CONF_HOST], + }, + ) + + @callback + def _async_create_entry_from_device(self, device: SmartDevice) -> FlowResult: + """Create a config entry from a smart device.""" + self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) + return self.async_create_entry( + title=f"{device.alias} {device.model}", + data={ + CONF_HOST: device.host, + }, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import step.""" + host = user_input[CONF_HOST] + try: + device = await self._async_try_connect(host, raise_on_progress=False) + except SmartDeviceException: + _LOGGER.error("Failed to import %s: cannot connect", host) + return self.async_abort(reason="cannot_connect") + return self._async_create_entry_from_device(device) + + async def _async_try_connect( + self, host: str, raise_on_progress: bool = True + ) -> SmartDevice: + """Try to connect.""" + self._async_abort_entries_match({CONF_HOST: host}) + device: SmartDevice = await Discover.discover_single(host) + await self.async_set_unique_id( + dr.format_mac(device.mac), raise_on_progress=raise_on_progress + ) + return device diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 60e06fd1ffe..6d4fcbea75d 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -1,28 +1,20 @@ """Const for TP-Link.""" from __future__ import annotations -import datetime +from typing import Final 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_CURRENT_A: Final = "current_a" +ATTR_CURRENT_POWER_W: Final = "current_power_w" +ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" +ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" -ATTR_CONFIG = "config" -ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" -ATTR_CURRENT_A = "current_a" +CONF_DIMMER: Final = "dimmer" +CONF_DISCOVERY: Final = "discovery" +CONF_LIGHT: Final = "light" +CONF_STRIP: Final = "strip" +CONF_SWITCH: Final = "switch" +CONF_SENSOR: Final = "sensor" -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] +PLATFORMS: Final = [CONF_LIGHT, CONF_SENSOR, CONF_SWITCH] diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py new file mode 100644 index 00000000000..2b33f817c63 --- /dev/null +++ b/homeassistant/components/tplink/coordinator.py @@ -0,0 +1,57 @@ +"""Component to embed TP-Link smart home devices.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from kasa import SmartDevice, SmartDeviceException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 0.35 + + +class TPLinkDataUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for a specific TPLink device.""" + + def __init__( + self, + hass: HomeAssistant, + device: SmartDevice, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.device = device + self.update_children = True + update_interval = timedelta(seconds=10) + super().__init__( + hass, + _LOGGER, + name=device.host, + update_interval=update_interval, + # We don't want an immediate refresh since the device + # takes a moment to reflect the state change + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def async_request_refresh_without_children(self) -> None: + """Request a refresh without the children.""" + # If the children do get updated this is ok as this is an + # optimization to reduce the number of requests on the device + # when we do not need it. + self.update_children = False + await self.async_request_refresh() + + async def _async_update_data(self) -> None: + """Fetch all device and sensor data from api.""" + try: + await self.device.update(update_children=self.update_children) + except SmartDeviceException as ex: + raise UpdateFailed(ex) from ex + finally: + self.update_children = True diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py new file mode 100644 index 00000000000..b331f70c5bb --- /dev/null +++ b/homeassistant/components/tplink/entity.py @@ -0,0 +1,63 @@ +"""Common code for tplink.""" +from __future__ import annotations + +from typing import Any, Callable, TypeVar, cast + +from kasa import SmartDevice + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TPLinkDataUpdateCoordinator + +WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) + + +def async_refresh_after(func: WrapFuncType) -> WrapFuncType: + """Define a wrapper to refresh after.""" + + async def _async_wrap( + self: CoordinatedTPLinkEntity, *args: Any, **kwargs: Any + ) -> None: + await func(self, *args, **kwargs) + await self.coordinator.async_request_refresh_without_children() + + return cast(WrapFuncType, _async_wrap) + + +class CoordinatedTPLinkEntity(CoordinatorEntity): + """Common base class for all coordinated tplink entities.""" + + coordinator: TPLinkDataUpdateCoordinator + + def __init__( + self, device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.device: SmartDevice = device + self._attr_unique_id = self.device.device_id + + @property + def name(self) -> str: + """Return the name of the Smart Plug.""" + return cast(str, self.device.alias) + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return { + "name": self.device.alias, + "model": self.device.model, + "manufacturer": "TP-Link", + "identifiers": {(DOMAIN, str(self.device.device_id))}, + "connections": {(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, + "sw_version": self.device.hw_info["sw_ver"], + } + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return bool(self.device.is_on) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 6d497812261..f1d936ecdfe 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -1,544 +1,154 @@ """Support for TPLink lights.""" from __future__ import annotations -import asyncio -from collections.abc import Mapping -from datetime import timedelta import logging -import re -import time -from typing import Any, NamedTuple, cast +from typing import Any -from pyHS100 import SmartBulb, SmartDeviceException +from kasa import SmartDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + ATTR_TRANSITION, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + SUPPORT_TRANSITION, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady -import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired, color_temperature_mired_to_kelvin as mired_to_kelvin, ) -import homeassistant.util.dt as dt_util -from . import CONF_LIGHT, DOMAIN as TPLINK_DOMAIN -from .common import add_available_devices - -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(seconds=5) -CURRENT_POWER_UPDATE_INTERVAL = timedelta(seconds=60) -HISTORICAL_POWER_UPDATE_INTERVAL = timedelta(minutes=60) +from .const import DOMAIN +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after _LOGGER = logging.getLogger(__name__) -ATTR_CURRENT_POWER_W = "current_power_w" -ATTR_DAILY_ENERGY_KWH = "daily_energy_kwh" -ATTR_MONTHLY_ENERGY_KWH = "monthly_energy_kwh" - -LIGHT_STATE_DFT_ON = "dft_on_state" -LIGHT_STATE_DFT_IGNORE = "ignore_default" -LIGHT_STATE_ON_OFF = "on_off" -LIGHT_STATE_RELAY_STATE = "relay_state" -LIGHT_STATE_BRIGHTNESS = "brightness" -LIGHT_STATE_COLOR_TEMP = "color_temp" -LIGHT_STATE_HUE = "hue" -LIGHT_STATE_SATURATION = "saturation" -LIGHT_STATE_ERROR_MSG = "err_msg" - -LIGHT_SYSINFO_MAC = "mac" -LIGHT_SYSINFO_ALIAS = "alias" -LIGHT_SYSINFO_MODEL = "model" -LIGHT_SYSINFO_IS_DIMMABLE = "is_dimmable" -LIGHT_SYSINFO_IS_VARIABLE_COLOR_TEMP = "is_variable_color_temp" -LIGHT_SYSINFO_IS_COLOR = "is_color" - -MAX_ATTEMPTS = 300 -SLEEP_TIME = 2 - -TPLINK_KELVIN = { - "LB130": (2500, 9000), - "LB120": (2700, 6500), - "LB230": (2500, 9000), - "KB130": (2500, 9000), - "KL130": (2500, 9000), - "KL125": (2500, 6500), - r"KL120\(EU\)": (2700, 6500), - r"KL120\(US\)": (2700, 5000), - r"KL430\(US\)": (2500, 9000), -} - -FALLBACK_MIN_COLOR = 2700 -FALLBACK_MAX_COLOR = 5000 - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up lights.""" - entities = await hass.async_add_executor_job( - add_available_devices, hass, CONF_LIGHT, TPLinkSmartBulb - ) - - if entities: - async_add_entities(entities, update_before_add=True) - - if hass.data[TPLINK_DOMAIN][f"{CONF_LIGHT}_remaining"]: - raise PlatformNotReady + """Set up switches.""" + coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + device = coordinator.device + if device.is_bulb or device.is_light_strip or device.is_dimmer: + async_add_entities([TPLinkSmartBulb(device, coordinator)]) -def brightness_to_percentage(byt): - """Convert brightness from absolute 0..255 to percentage.""" - return round((byt * 100.0) / 255.0) - - -def brightness_from_percentage(percent): - """Convert percentage to absolute value 0..255.""" - return round((percent * 255.0) / 100.0) - - -class LightState(NamedTuple): - """Light state.""" - - state: bool - brightness: int - color_temp: float - hs: tuple[int, int] - - def to_param(self): - """Return a version that we can send to the bulb.""" - color_temp = None - if self.color_temp: - color_temp = mired_to_kelvin(self.color_temp) - - return { - LIGHT_STATE_ON_OFF: 1 if self.state else 0, - LIGHT_STATE_DFT_IGNORE: 1 if self.state else 0, - LIGHT_STATE_BRIGHTNESS: brightness_to_percentage(self.brightness), - LIGHT_STATE_COLOR_TEMP: color_temp, - LIGHT_STATE_HUE: self.hs[0] if self.hs else 0, - LIGHT_STATE_SATURATION: self.hs[1] if self.hs else 0, - } - - -class LightFeatures(NamedTuple): - """Light features.""" - - sysinfo: dict[str, Any] - mac: str - alias: str - model: str - supported_features: int - min_mireds: float - max_mireds: float - has_emeter: bool - - -class TPLinkSmartBulb(LightEntity): +class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" - def __init__(self, smartbulb: SmartBulb) -> None: - """Initialize the bulb.""" - self.smartbulb = smartbulb - self._light_features = cast(LightFeatures, None) - self._light_state = cast(LightState, None) - self._is_available = True - self._is_setting_light_state = False - self._last_current_power_update = None - self._last_historical_power_update = None - self._emeter_params = {} + coordinator: TPLinkDataUpdateCoordinator - self._host = None - self._alias = None - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._light_features.mac - - @property - def name(self) -> str | None: - """Return the name of the Smart Bulb.""" - return self._light_features.alias - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return { - "name": self._light_features.alias, - "model": self._light_features.model, - "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self._light_features.mac)}, - "sw_version": self._light_features.sysinfo["sw_ver"], - } - - @property - def available(self) -> bool: - """Return if bulb is available.""" - return self._is_available - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the state attributes of the device.""" - return self._emeter_params + def __init__( + self, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + ) -> None: + """Initialize the switch.""" + super().__init__(device, coordinator) + # For backwards compat with pyHS100 + self._attr_unique_id = self.device.mac.replace(":", "").upper() + @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs: - brightness = int(kwargs[ATTR_BRIGHTNESS]) - elif self._light_state.brightness is not None: - brightness = self._light_state.brightness - else: - brightness = 255 + transition = kwargs.get(ATTR_TRANSITION) + if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: + brightness = round((brightness * 100.0) / 255.0) + # Handle turning to temp mode if ATTR_COLOR_TEMP in kwargs: - color_tmp = int(kwargs[ATTR_COLOR_TEMP]) - else: - color_tmp = self._light_state.color_temp + color_tmp = mired_to_kelvin(int(kwargs[ATTR_COLOR_TEMP])) + _LOGGER.debug("Changing color temp to %s", color_tmp) + await self.device.set_color_temp( + color_tmp, brightness=brightness, transition=transition + ) + return + # Handling turning to hs color mode if ATTR_HS_COLOR in kwargs: # TP-Link requires integers. - hue_sat = tuple(int(val) for val in kwargs[ATTR_HS_COLOR]) + hue, sat = tuple(int(val) for val in kwargs[ATTR_HS_COLOR]) + await self.device.set_hsv(hue, sat, brightness, transition=transition) + return - # TP-Link cannot have both color temp and hue_sat - color_tmp = 0 + # Fallback to adjusting brightness or turning the bulb on + if brightness is not None: + await self.device.set_brightness(brightness, transition=transition) else: - hue_sat = self._light_state.hs - - await self._async_set_light_state_retry( - self._light_state, - self._light_state._replace( - state=True, - brightness=brightness, - color_temp=color_tmp, - hs=hue_sat, - ), - ) + await self.device.turn_on(transition=transition) + @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self._async_set_light_state_retry( - self._light_state, - self._light_state._replace(state=False), - ) + await self.device.turn_off(transition=kwargs.get(ATTR_TRANSITION)) @property def min_mireds(self) -> int: """Return minimum supported color temperature.""" - return self._light_features.min_mireds + return kelvin_to_mired(self.device.valid_temperature_range.max) @property def max_mireds(self) -> int: """Return maximum supported color temperature.""" - return self._light_features.max_mireds + return kelvin_to_mired(self.device.valid_temperature_range.min) @property def color_temp(self) -> int | None: """Return the color temperature of this light in mireds for HA.""" - return self._light_state.color_temp + return kelvin_to_mired(self.device.color_temp) @property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" - return self._light_state.brightness + return round((self.device.brightness * 255.0) / 100.0) @property - def hs_color(self) -> tuple[float, float] | None: + def hs_color(self) -> tuple[int, int] | None: """Return the color.""" - return self._light_state.hs - - @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self._light_state.state - - def attempt_update(self, update_attempt: int) -> bool: - """Attempt to get details the TP-Link bulb.""" - # State is currently being set, ignore. - if self._is_setting_light_state: - return False - - try: - if not self._light_features: - self._light_features = self._get_light_features() - self._alias = self._light_features.alias - self._host = self.smartbulb.host - self._light_state = self._get_light_state() - 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 + hue, saturation, _ = self.device.hsv + return hue, saturation @property def supported_features(self) -> int: """Flag supported features.""" - return self._light_features.supported_features + return SUPPORT_TRANSITION - def _get_valid_temperature_range(self) -> tuple[int, int]: - """Return the device-specific white temperature range (in Kelvin). + @property + def supported_color_modes(self) -> set[str] | None: + """Return list of available color modes.""" + modes = set() + if self.device.is_variable_color_temp: + modes.add(COLOR_MODE_COLOR_TEMP) + if self.device.is_color: + modes.add(COLOR_MODE_HS) + if self.device.is_dimmable: + modes.add(COLOR_MODE_BRIGHTNESS) - :return: White temperature range in Kelvin (minimum, maximum) - """ - model = self.smartbulb.sys_info[LIGHT_SYSINFO_MODEL] - for obj, temp_range in TPLINK_KELVIN.items(): - if re.match(obj, model): - return temp_range - # pyHS100 is abandoned, but some bulb definitions aren't present - # use "safe" values for something that advertises color temperature - return FALLBACK_MIN_COLOR, FALLBACK_MAX_COLOR + if not modes: + modes.add(COLOR_MODE_ONOFF) - def _get_light_features(self) -> LightFeatures: - """Determine all supported features in one go.""" - sysinfo = self.smartbulb.sys_info - supported_features = 0 - # Calling api here as it reformats - mac = self.smartbulb.mac - alias = sysinfo[LIGHT_SYSINFO_ALIAS] - model = sysinfo[LIGHT_SYSINFO_MODEL] - min_mireds = None - max_mireds = None - has_emeter = self.smartbulb.has_emeter + return modes - if sysinfo.get(LIGHT_SYSINFO_IS_DIMMABLE) or LIGHT_STATE_BRIGHTNESS in sysinfo: - supported_features += SUPPORT_BRIGHTNESS - if sysinfo.get(LIGHT_SYSINFO_IS_VARIABLE_COLOR_TEMP): - supported_features += SUPPORT_COLOR_TEMP - max_range, min_range = self._get_valid_temperature_range() - min_mireds = kelvin_to_mired(min_range) - max_mireds = kelvin_to_mired(max_range) - if sysinfo.get(LIGHT_SYSINFO_IS_COLOR): - supported_features += SUPPORT_COLOR + @property + def color_mode(self) -> str | None: + """Return the active color mode.""" + if self.device.is_color: + if self.device.color_temp: + return COLOR_MODE_COLOR_TEMP + return COLOR_MODE_HS + if self.device.is_variable_color_temp: + return COLOR_MODE_COLOR_TEMP - return LightFeatures( - sysinfo=sysinfo, - mac=mac, - alias=alias, - model=model, - supported_features=supported_features, - min_mireds=min_mireds, - max_mireds=max_mireds, - has_emeter=has_emeter, - ) - - def _light_state_from_params(self, light_state_params: Any) -> LightState: - brightness = None - color_temp = None - hue_saturation = None - light_features = self._light_features - - state = bool(light_state_params[LIGHT_STATE_ON_OFF]) - - if not state and LIGHT_STATE_DFT_ON in light_state_params: - light_state_params = light_state_params[LIGHT_STATE_DFT_ON] - - if light_features.supported_features & SUPPORT_BRIGHTNESS: - brightness = brightness_from_percentage( - light_state_params[LIGHT_STATE_BRIGHTNESS] - ) - - if ( - light_features.supported_features & SUPPORT_COLOR_TEMP - and light_state_params.get(LIGHT_STATE_COLOR_TEMP) is not None - and light_state_params[LIGHT_STATE_COLOR_TEMP] != 0 - ): - color_temp = kelvin_to_mired(light_state_params[LIGHT_STATE_COLOR_TEMP]) - - if color_temp is None and light_features.supported_features & SUPPORT_COLOR: - hue_saturation = ( - light_state_params[LIGHT_STATE_HUE], - light_state_params[LIGHT_STATE_SATURATION], - ) - - return LightState( - state=state, - brightness=brightness, - color_temp=color_temp, - hs=hue_saturation, - ) - - def _get_light_state(self) -> LightState: - """Get the light state.""" - self._update_emeter() - return self._light_state_from_params(self._get_device_state()) - - def _update_emeter(self) -> None: - if not self._light_features.has_emeter: - return - - now = dt_util.utcnow() - if ( - not self._last_current_power_update - or self._last_current_power_update + CURRENT_POWER_UPDATE_INTERVAL < now - ): - self._last_current_power_update = now - self._emeter_params[ATTR_CURRENT_POWER_W] = round( - float(self.smartbulb.current_consumption()), 1 - ) - - if ( - not self._last_historical_power_update - or self._last_historical_power_update + HISTORICAL_POWER_UPDATE_INTERVAL - < now - ): - self._last_historical_power_update = now - daily_statistics = self.smartbulb.get_emeter_daily() - monthly_statistics = self.smartbulb.get_emeter_monthly() - try: - self._emeter_params[ATTR_DAILY_ENERGY_KWH] = round( - float(daily_statistics[int(time.strftime("%d"))]), 3 - ) - self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] = round( - float(monthly_statistics[int(time.strftime("%m"))]), 3 - ) - except KeyError: - # device returned no daily/monthly history - pass - - async def _async_set_light_state_retry( - self, old_light_state: LightState, new_light_state: LightState - ) -> None: - """Set the light state with retry.""" - # Tell the device to set the states. - if not _light_state_diff(old_light_state, new_light_state): - # Nothing to do, avoid the executor - return - - self._is_setting_light_state = True - try: - light_state_params = await self.hass.async_add_executor_job( - self._set_light_state, old_light_state, new_light_state - ) - self._is_available = True - self._is_setting_light_state = False - if LIGHT_STATE_ERROR_MSG in light_state_params: - raise HomeAssistantError(light_state_params[LIGHT_STATE_ERROR_MSG]) - # Some devices do not report the new state in their responses, so we skip - # set here and wait for the next poll to update the values. See #47600 - if LIGHT_STATE_ON_OFF in light_state_params: - self._light_state = self._light_state_from_params(light_state_params) - return - except (SmartDeviceException, OSError): - pass - - try: - _LOGGER.debug("Retrying setting light state") - light_state_params = await self.hass.async_add_executor_job( - self._set_light_state, old_light_state, new_light_state - ) - self._is_available = True - if LIGHT_STATE_ERROR_MSG in light_state_params: - raise HomeAssistantError(light_state_params[LIGHT_STATE_ERROR_MSG]) - self._light_state = self._light_state_from_params(light_state_params) - except (SmartDeviceException, OSError) as ex: - self._is_available = False - _LOGGER.warning("Could not set data for %s: %s", self.smartbulb.host, ex) - - self._is_setting_light_state = False - - def _set_light_state( - self, old_light_state: LightState, new_light_state: LightState - ) -> None: - """Set the light state.""" - diff = _light_state_diff(old_light_state, new_light_state) - - if not diff: - return - - return self._set_device_state(diff) - - def _get_device_state(self) -> dict: - """State of the bulb or smart dimmer switch.""" - if isinstance(self.smartbulb, SmartBulb): - return self.smartbulb.get_light_state() - - sysinfo = self.smartbulb.sys_info - # Its not really a bulb, its a dimmable SmartPlug (aka Wall Switch) - return { - LIGHT_STATE_ON_OFF: sysinfo[LIGHT_STATE_RELAY_STATE], - LIGHT_STATE_BRIGHTNESS: sysinfo.get(LIGHT_STATE_BRIGHTNESS, 0), - LIGHT_STATE_COLOR_TEMP: 0, - LIGHT_STATE_HUE: 0, - LIGHT_STATE_SATURATION: 0, - } - - def _set_device_state(self, state): - """Set state of the bulb or smart dimmer switch.""" - if isinstance(self.smartbulb, SmartBulb): - return self.smartbulb.set_light_state(state) - - # Its not really a bulb, its a dimmable SmartPlug (aka Wall Switch) - if LIGHT_STATE_BRIGHTNESS in state: - # Brightness of 0 is accepted by the - # device but the underlying library rejects it - # so we turn off instead. - if state[LIGHT_STATE_BRIGHTNESS]: - self.smartbulb.brightness = state[LIGHT_STATE_BRIGHTNESS] - else: - self.smartbulb.state = self.smartbulb.SWITCH_STATE_OFF - elif LIGHT_STATE_ON_OFF in state: - if state[LIGHT_STATE_ON_OFF]: - self.smartbulb.state = self.smartbulb.SWITCH_STATE_ON - else: - self.smartbulb.state = self.smartbulb.SWITCH_STATE_OFF - - return self._get_device_state() - - async def async_update(self) -> None: - """Update the TP-Link bulb'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._host, - self._alias, - ) - self._is_available = False - - -def _light_state_diff( - old_light_state: LightState, new_light_state: LightState -) -> dict[str, Any]: - old_state_param = old_light_state.to_param() - new_state_param = new_light_state.to_param() - - return { - key: value - for key, value in new_state_param.items() - if new_state_param.get(key) != old_state_param.get(key) - } + return COLOR_MODE_BRIGHTNESS diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index fa8c32c35d7..0c45ca84ac6 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -3,10 +3,28 @@ "name": "TP-Link Kasa Smart", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tplink", - "requirements": ["pyHS100==0.3.5.2"], + "requirements": ["python-kasa==0.4.0"], "codeowners": ["@rytilahti", "@thegardenmonkey"], + "dependencies": ["network"], + "quality_scale": "platinum", "iot_class": "local_polling", "dhcp": [ + { + "hostname": "k[lp]*", + "macaddress": "403F8C*" + }, + { + "hostname": "ep*", + "macaddress": "E848B8*" + }, + { + "hostname": "k[lp]*", + "macaddress": "E848B8*" + }, + { + "hostname": "k[lp]*", + "macaddress": "909A4A*" + }, { "hostname": "hs*", "macaddress": "1C3BF3*" @@ -27,6 +45,18 @@ "hostname": "hs*", "macaddress": "B09575*" }, + { + "hostname": "hs*", + "macaddress": "C006C3*" + }, + { + "hostname": "ep*", + "macaddress": "003192*" + }, + { + "hostname": "k[lp]*", + "macaddress": "003192*" + }, { "hostname": "k[lp]*", "macaddress": "1C3BF3*" @@ -47,6 +77,10 @@ "hostname": "k[lp]*", "macaddress": "B09575*" }, + { + "hostname": "k[lp]*", + "macaddress": "C006C3*" + }, { "hostname": "lb*", "macaddress": "1C3BF3*" diff --git a/homeassistant/components/tplink/migration.py b/homeassistant/components/tplink/migration.py new file mode 100644 index 00000000000..af81323d39f --- /dev/null +++ b/homeassistant/components/tplink/migration.py @@ -0,0 +1,109 @@ +"""Component to embed TP-Link smart home devices.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_DIMMER, CONF_LIGHT, CONF_STRIP, CONF_SWITCH, DOMAIN + + +async def async_cleanup_legacy_entry( + hass: HomeAssistant, + legacy_entry_id: str, +) -> None: + """Cleanup the legacy entry if the migration is successful.""" + entity_registry = er.async_get(hass) + if not er.async_entries_for_config_entry(entity_registry, legacy_entry_id): + await hass.config_entries.async_remove(legacy_entry_id) + + +@callback +def async_migrate_legacy_entries( + hass: HomeAssistant, + hosts_by_mac: dict[str, str], + config_entries_by_mac: dict[str, ConfigEntry], + legacy_entry: ConfigEntry, +) -> None: + """Migrate the legacy config entries to have an entry per device.""" + device_registry = dr.async_get(hass) + for dev_entry in dr.async_entries_for_config_entry( + device_registry, legacy_entry.entry_id + ): + for connection_type, mac in dev_entry.connections: + if ( + connection_type != dr.CONNECTION_NETWORK_MAC + or mac in config_entries_by_mac + ): + continue + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "migration"}, + data={ + CONF_HOST: hosts_by_mac.get(mac), + CONF_MAC: mac, + CONF_NAME: dev_entry.name or f"TP-Link device {mac}", + }, + ) + ) + + async def _async_cleanup_legacy_entry(_now: datetime) -> None: + await async_cleanup_legacy_entry(hass, legacy_entry.entry_id) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_cleanup_legacy_entry) + + +@callback +def async_migrate_yaml_entries(hass: HomeAssistant, conf: ConfigType) -> None: + """Migrate yaml to config entries.""" + for device_type in (CONF_LIGHT, CONF_SWITCH, CONF_STRIP, CONF_DIMMER): + for device in conf.get(device_type, []): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: device[CONF_HOST], + }, + ) + ) + + +async def async_migrate_entities_devices( + hass: HomeAssistant, legacy_entry_id: str, new_entry: ConfigEntry +) -> None: + """Move entities and devices to the new config entry.""" + migrated_devices = [] + device_registry = dr.async_get(hass) + for dev_entry in dr.async_entries_for_config_entry( + device_registry, legacy_entry_id + ): + for connection_type, value in dev_entry.connections: + if ( + connection_type == dr.CONNECTION_NETWORK_MAC + and value == new_entry.unique_id + ): + migrated_devices.append(dev_entry.id) + device_registry.async_update_device( + dev_entry.id, add_config_entry_id=new_entry.entry_id + ) + + entity_registry = er.async_get(hass) + for reg_entity in er.async_entries_for_config_entry( + entity_registry, legacy_entry_id + ): + if reg_entity.device_id in migrated_devices: + entity_registry.async_update_entity( + reg_entity.entity_id, config_entry_id=new_entry.entry_id + ) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 4d2ed5eee30..9bd4a056d33 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -1,9 +1,10 @@ """Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" from __future__ import annotations -from typing import Any, Final +from dataclasses import dataclass +from typing import cast -from pyHS100 import SmartPlug +from kasa import SmartDevice from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -11,13 +12,9 @@ from homeassistant.components.sensor import ( 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, @@ -28,65 +25,92 @@ from homeassistant.const import ( 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 . import legacy_device_id from .const import ( - CONF_EMETER_PARAMS, - CONF_MODEL, - CONF_SW_VERSION, - CONF_SWITCH, - COORDINATORS, - DOMAIN as TPLINK_DOMAIN, + ATTR_CURRENT_A, + ATTR_CURRENT_POWER_W, + ATTR_TODAY_ENERGY_KWH, + ATTR_TOTAL_ENERGY_KWH, + DOMAIN, ) +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity -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( +@dataclass +class TPLinkSensorEntityDescription(SensorEntityDescription): + """Describes TPLink sensor entity.""" + + emeter_attr: str | None = None + precision: int | None = None + + +ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( + TPLinkSensorEntityDescription( key=ATTR_CURRENT_POWER_W, native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, name="Current Consumption", + emeter_attr="power", + precision=1, ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_TOTAL_ENERGY_KWH, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Total Consumption", + emeter_attr="total", + precision=3, ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, name="Today's Consumption", + precision=3, ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, name="Voltage", + emeter_attr="voltage", + precision=1, ), - SensorEntityDescription( + TPLinkSensorEntityDescription( key=ATTR_CURRENT_A, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, name="Current", + emeter_attr="current", + precision=2, ), -] +) + + +def async_emeter_from_device( + device: SmartDevice, description: TPLinkSensorEntityDescription +) -> float | None: + """Map a sensor key to the device attribute.""" + if attr := description.emeter_attr: + val = getattr(device.emeter_realtime, attr) + if val is None: + return None + return round(cast(float, val), description.precision) + + # ATTR_TODAY_ENERGY_KWH + if (emeter_today := device.emeter_today) is not None: + return round(cast(float, emeter_today), description.precision) + # today's consumption not available, when device was off all the day + # bulb's do not report this information, so filter it out + return None if device.is_bulb else 0.0 async def async_setup_entry( @@ -94,62 +118,58 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up sensors.""" + coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] 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 + parent = coordinator.device + if not parent.has_emeter: + return + + def _async_sensors_for_device(device: SmartDevice) -> list[SmartPlugSensor]: + return [ + SmartPlugSensor(device, coordinator, description) + for description in ENERGY_SENSORS + if async_emeter_from_device(device, description) is not None ] - 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)) + + if parent.is_strip: + # Historically we only add the children if the device is a strip + for child in parent.children: + entities.extend(_async_sensors_for_device(child)) + else: + entities.extend(_async_sensors_for_device(parent)) async_add_entities(entities) -class SmartPlugSensor(CoordinatorEntity, SensorEntity): +class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): """Representation of a TPLink Smart Plug energy sensor.""" + coordinator: TPLinkDataUpdateCoordinator + entity_description: TPLinkSensorEntityDescription + def __init__( self, - smartplug: SmartPlug, - coordinator: DataUpdateCoordinator, - description: SensorEntityDescription, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkSensorEntityDescription, ) -> None: """Initialize the switch.""" - super().__init__(coordinator) - self.smartplug = smartplug + super().__init__(device, coordinator) self.entity_description = description - self._attr_name = f"{coordinator.data[CONF_ALIAS]} {description.name}" + self._attr_unique_id = ( + f"{legacy_device_id(self.device)}_{self.entity_description.key}" + ) @property - def data(self) -> dict[str, Any]: - """Return data from DataUpdateCoordinator.""" - return self.coordinator.data + def name(self) -> str: + """Return the name of the Smart Plug. + + Overridden to include the description. + """ + return f"{self.device.alias} {self.entity_description.name}" @property def native_value(self) -> float | None: """Return the sensors state.""" - 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], - } + return async_emeter_from_device(self.device, self.entity_description) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index a10c44b9252..4f3b34beb9c 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -1,12 +1,27 @@ { "config": { + "flow_title": "{name} {model} ({host})", "step": { - "confirm": { - "description": "Do you want to setup TP-Link smart devices?" + "user": { + "description": "If you leave the host empty, discovery will be used to find devices.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "pick_device": { + "data": { + "device": "Device" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name} {model} ({host})?" } }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } } diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 10cf5c64d75..f0d299e21c8 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,30 +1,22 @@ """Support for TPLink HS100/HS110/HS200 smart switch.""" from __future__ import annotations +import logging from typing import Any -from pyHS100 import SmartPlug +from kasa import SmartDevice from homeassistant.components.switch import SwitchEntity -from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_MAC, CONF_STATE 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_MODEL, - CONF_SW_VERSION, - CONF_SWITCH, - COORDINATORS, - DOMAIN as TPLINK_DOMAIN, -) +from . import legacy_device_id +from .const import DOMAIN +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -33,65 +25,43 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - 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)) + coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + device = coordinator.device + if not device.is_plug and not device.is_strip: + return + entities = [] + if device.is_strip: + # Historically we only add the children if the device is a strip + _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) + for child in device.children: + entities.append(SmartPlugSwitch(child, coordinator)) + else: + entities.append(SmartPlugSwitch(device, coordinator)) async_add_entities(entities) -class SmartPlugSwitch(CoordinatorEntity, SwitchEntity): +class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" + coordinator: TPLinkDataUpdateCoordinator + def __init__( - self, smartplug: SmartPlug, coordinator: DataUpdateCoordinator + self, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, ) -> None: """Initialize the switch.""" - super().__init__(coordinator) - self.smartplug = smartplug - - @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.data[CONF_DEVICE_ID] - - @property - def name(self) -> str | None: - """Return the name of the Smart Plug.""" - return self.data[CONF_ALIAS] - - @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], - } - - @property - def is_on(self) -> bool | None: - """Return true if switch is on.""" - return self.data[CONF_STATE] + super().__init__(device, coordinator) + # For backwards compat with pyHS100 + self._attr_unique_id = legacy_device_id(device) + @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.hass.async_add_executor_job(self.smartplug.turn_on) - await self.coordinator.async_refresh() + await self.device.turn_on() + @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.hass.async_add_executor_job(self.smartplug.turn_off) - await self.coordinator.async_refresh() + await self.device.turn_off() diff --git a/homeassistant/components/tplink/translations/ca.json b/homeassistant/components/tplink/translations/ca.json index 69dfc1b4b9d..4dfb749a9d7 100644 --- a/homeassistant/components/tplink/translations/ca.json +++ b/homeassistant/components/tplink/translations/ca.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", "no_devices_found": "No s'han trobat dispositius a la xarxa", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Vols configurar dispositius intel\u00b7ligents TP-Link?" + }, + "discovery_confirm": { + "description": "Vols configurar {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Dispositiu" + } + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Si deixes l'amfitri\u00f3 buit, s'utilitzar\u00e0 el descobriment per cercar dispositius." } } } diff --git a/homeassistant/components/tplink/translations/de.json b/homeassistant/components/tplink/translations/de.json index 6f804a6eeef..4d6a07b881e 100644 --- a/homeassistant/components/tplink/translations/de.json +++ b/homeassistant/components/tplink/translations/de.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "M\u00f6chtest du TP-Link Smart Devices einrichten?" + }, + "discovery_confirm": { + "description": "M\u00f6chtest du {name} {model} ({host}) einrichten?" + }, + "pick_device": { + "data": { + "device": "Ger\u00e4t" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Wenn du den Host leer l\u00e4sst, wird die Erkennung verwendet, um Ger\u00e4te zu finden." } } } diff --git a/homeassistant/components/tplink/translations/en.json b/homeassistant/components/tplink/translations/en.json index 1105f6a383b..da4681145d8 100644 --- a/homeassistant/components/tplink/translations/en.json +++ b/homeassistant/components/tplink/translations/en.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Device is already configured", "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Do you want to setup TP-Link smart devices?" + }, + "discovery_confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Device" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "If you leave the host empty, discovery will be used to find devices." } } } diff --git a/homeassistant/components/tplink/translations/et.json b/homeassistant/components/tplink/translations/et.json index 972e581fc61..12c4f3d6f84 100644 --- a/homeassistant/components/tplink/translations/et.json +++ b/homeassistant/components/tplink/translations/et.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "no_devices_found": "V\u00f5rgust ei leitud seadmeid", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{name} {model} ( {host} )", "step": { "confirm": { "description": "Kas soovid seadistada TP-Linki nutiseadmeid?" + }, + "discovery_confirm": { + "description": "Kas seadistada {name}{model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Seade" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Kui j\u00e4tad hosti t\u00fchjaks kasutatakse seadmete leidmiseks avastamist." } } } diff --git a/homeassistant/components/tplink/translations/fr.json b/homeassistant/components/tplink/translations/fr.json index 43ea1d1b111..f36b3865e55 100644 --- a/homeassistant/components/tplink/translations/fr.json +++ b/homeassistant/components/tplink/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique TP-Link trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration est n\u00e9cessaire." + "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": { diff --git a/homeassistant/components/tplink/translations/he.json b/homeassistant/components/tplink/translations/he.json index 888c65226dc..621ee6bebc9 100644 --- a/homeassistant/components/tplink/translations/he.json +++ b/homeassistant/components/tplink/translations/he.json @@ -1,12 +1,26 @@ { "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\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." }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05db\u05de\u05d9\u05dd \u05e9\u05dc TP-Link ?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05db\u05de\u05d9\u05dd \u05e9\u05dc TP-Link ?" + }, + "pick_device": { + "data": { + "device": "\u05d4\u05ea\u05e7\u05df" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } } } } diff --git a/homeassistant/components/tplink/translations/hu.json b/homeassistant/components/tplink/translations/hu.json index bcfb467538d..c00744d0dcf 100644 --- a/homeassistant/components/tplink/translations/hu.json +++ b/homeassistant/components/tplink/translations/hu.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van konfigur\u00e1lva", "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." }, + "error": { + "cannot_connect": "A csatlakoz\u00e1s sikertelen" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a TP-Link intelligens eszk\u00f6z\u00f6ket?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a TP-Link intelligens eszk\u00f6zeit?" + }, + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Eszk\u00f6z" + } + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." } } } diff --git a/homeassistant/components/tplink/translations/it.json b/homeassistant/components/tplink/translations/it.json index 8940b1c8ee6..3fd30d8d12c 100644 --- a/homeassistant/components/tplink/translations/it.json +++ b/homeassistant/components/tplink/translations/it.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "no_devices_found": "Nessun dispositivo trovato sulla rete", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Vuoi configurare i dispositivi intelligenti TP-Link?" + }, + "discovery_confirm": { + "description": "Vuoi configurare {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Dispositivo" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Se si lascia vuoto l'host, l'individuazione verr\u00e0 utilizzata per trovare i dispositivi." } } } diff --git a/homeassistant/components/tplink/translations/nl.json b/homeassistant/components/tplink/translations/nl.json index 362645d9f19..f6cf6a21e72 100644 --- a/homeassistant/components/tplink/translations/nl.json +++ b/homeassistant/components/tplink/translations/nl.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Apparaat is al geconfigureerd", "no_devices_found": "Geen apparaten gevonden op het netwerk", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, + "error": { + "cannot_connect": "Kon geen verbinding maken" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Wil je TP-Link slimme apparaten instellen?" + }, + "discovery_confirm": { + "description": "Wilt u {name} {model} ({host}) instellen?" + }, + "pick_device": { + "data": { + "device": "Apparaat" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Als u de host leeg laat, wordt detectie gebruikt om apparaten te vinden." } } } diff --git a/homeassistant/components/tplink/translations/no.json b/homeassistant/components/tplink/translations/no.json index 1d1d624ab40..6c7bd7dcbf4 100644 --- a/homeassistant/components/tplink/translations/no.json +++ b/homeassistant/components/tplink/translations/no.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{name} {model} ( {host} )", "step": { "confirm": { "description": "Vil du konfigurere TP-Link smart enheter?" + }, + "discovery_confirm": { + "description": "Vil du konfigurere {name} {model} ( {host} )?" + }, + "pick_device": { + "data": { + "device": "Enhet" + } + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Hvis du lar verten st\u00e5 tom, brukes automatisk oppdagelse til \u00e5 finne enheter" } } } diff --git a/homeassistant/components/tplink/translations/ru.json b/homeassistant/components/tplink/translations/ru.json index 4df755bee4f..47f1459e572 100644 --- a/homeassistant/components/tplink/translations/ru.json +++ b/homeassistant/components/tplink/translations/ru.json @@ -1,12 +1,31 @@ { "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.", "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." }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c TP-Link Smart Home?" + }, + "discovery_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0415\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438." } } } diff --git a/homeassistant/components/tplink/translations/zh-Hant.json b/homeassistant/components/tplink/translations/zh-Hant.json index 2fac2ac142d..153783b1b90 100644 --- a/homeassistant/components/tplink/translations/zh-Hant.json +++ b/homeassistant/components/tplink/translations/zh-Hant.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a TP-Link \u667a\u80fd\u88dd\u7f6e\uff1f" + }, + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} {model} ({host})\uff1f" + }, + "pick_device": { + "data": { + "device": "\u88dd\u7f6e" + } + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u5047\u5982\u4e3b\u6a5f\u7aef\u4f4d\u5740\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u63a2\u7d22\u6240\u6709\u53ef\u7528\u88dd\u7f6e\u3002" } } } diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 439bdc6f09e..916b0f71169 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -3,12 +3,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER -from homeassistant.const import ( - ATTR_ID, - CONF_WEBHOOK_ID, - HTTP_OK, - HTTP_UNPROCESSABLE_ENTITY, -) +from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, HTTP_UNPROCESSABLE_ENTITY from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -31,7 +26,7 @@ PLATFORMS = [DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" -DEFAULT_ACCURACY = HTTP_OK +DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 @@ -87,7 +82,7 @@ async def handle_webhook(hass, webhook_id, request): attrs, ) - return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + return web.Response(text=f"Setting location for {device}") async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/traccar/translations/hu.json b/homeassistant/components/traccar/translations/hu.json index 94fc9198921..902b4ea5231 100644 --- a/homeassistant/components/traccar/translations/hu.json +++ b/homeassistant/components/traccar/translations/hu.json @@ -2,10 +2,10 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Traccar-ban. \n\n Haszn\u00e1lja a k\u00f6vetkez\u0151 URL-t: `{webhook_url}`\n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t]({docs_url})." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Traccar-ban. \n\nHaszn\u00e1lja a k\u00f6vetkez\u0151 URL-t: `{webhook_url}`\n\nTov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1ssa a [dokument\u00e1ci\u00f3t]({docs_url})." }, "step": { "user": { diff --git a/homeassistant/components/trackr/__init__.py b/homeassistant/components/trackr/__init__.py deleted file mode 100644 index b78eb8078a2..00000000000 --- a/homeassistant/components/trackr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The trackr component.""" diff --git a/homeassistant/components/trackr/device_tracker.py b/homeassistant/components/trackr/device_tracker.py deleted file mode 100644 index c08a990ea16..00000000000 --- a/homeassistant/components/trackr/device_tracker.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Support for the TrackR platform.""" -import logging - -from pytrackr.api import trackrApiInterface -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, -) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_utc_time_change -from homeassistant.util import slugify - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - - -def setup_scanner(hass, config: dict, see, discovery_info=None): - """Validate the configuration and return a TrackR scanner.""" - TrackRDeviceScanner(hass, config, see) - return True - - -class TrackRDeviceScanner: - """A class representing a TrackR device.""" - - def __init__(self, hass, config: dict, see) -> None: - """Initialize the TrackR device scanner.""" - - self.hass = hass - self.api = trackrApiInterface( - config.get(CONF_USERNAME), config.get(CONF_PASSWORD) - ) - self.see = see - self.devices = self.api.get_trackrs() - self._update_info() - - track_utc_time_change(self.hass, self._update_info, second=range(0, 60, 30)) - - def _update_info(self, now=None) -> None: - """Update the device info.""" - _LOGGER.debug("Updating devices %s", now) - - # Update self.devices to collect new devices added - # to the users account. - self.devices = self.api.get_trackrs() - - for trackr in self.devices: - trackr.update_state() - trackr_id = trackr.tracker_id() - trackr_device_id = trackr.id() - lost = trackr.lost() - dev_id = slugify(trackr.name()) - if dev_id is None: - dev_id = trackr_id - location = trackr.last_known_location() - lat = location["latitude"] - lon = location["longitude"] - - attrs = { - "last_updated": trackr.last_updated(), - "last_seen": trackr.last_time_seen(), - "trackr_id": trackr_id, - "id": trackr_device_id, - "lost": lost, - "battery_level": trackr.battery_level(), - } - - self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs) diff --git a/homeassistant/components/trackr/manifest.json b/homeassistant/components/trackr/manifest.json deleted file mode 100644 index 04a629d49c6..00000000000 --- a/homeassistant/components/trackr/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "trackr", - "name": "TrackR", - "documentation": "https://www.home-assistant.io/integrations/trackr", - "requirements": ["pytrackr==0.0.5"], - "codeowners": [], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 60014852895..be612ef5cc7 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -2,12 +2,14 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging import aiotractive from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, @@ -19,27 +21,43 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( + ATTR_BUZZER, ATTR_DAILY_GOAL, + ATTR_LED, + ATTR_LIVE_TRACKING, ATTR_MINUTES_ACTIVE, + CLIENT, DOMAIN, RECONNECT_INTERVAL, SERVER_UNAVAILABLE, + TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, ) -PLATFORMS = ["device_tracker", "sensor"] +PLATFORMS = ["binary_sensor", "device_tracker", "sensor", "switch"] _LOGGER = logging.getLogger(__name__) +@dataclass +class Trackables: + """A class that describes trackables.""" + + tracker: aiotractive.tracker.Tracker + trackable: dict + tracker_details: dict + hw_info: dict + pos_report: dict + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up tractive from a config entry.""" data = entry.data - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) client = aiotractive.Tractive( data[CONF_EMAIL], data[CONF_PASSWORD], session=async_get_clientsession(hass) @@ -56,7 +74,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: tractive = TractiveClient(hass, client, creds["user_id"]) tractive.subscribe() - hass.data[DOMAIN][entry.entry_id] = tractive + try: + trackable_objects = await client.trackable_objects() + trackables = await asyncio.gather( + *(_generate_trackables(client, item) for item in trackable_objects) + ) + except aiotractive.exceptions.TractiveError as error: + await tractive.unsubscribe() + raise ConfigEntryNotReady from error + + # When the pet defined in Tractive has no tracker linked we get None as `trackable`. + # So we have to remove None values from trackables list. + trackables = [item for item in trackables if item] + + hass.data[DOMAIN][entry.entry_id][CLIENT] = tractive + hass.data[DOMAIN][entry.entry_id][TRACKABLES] = trackables hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -70,12 +102,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _generate_trackables(client, trackable): + """Generate trackables.""" + trackable = await trackable.details() + + # Check that the pet has tracker linked. + if not trackable["device_id"]: + return + + tracker = client.tracker(trackable["device_id"]) + + tracker_details, hw_info, pos_report = await asyncio.gather( + tracker.details(), tracker.hw_info(), tracker.pos_report() + ) + + return Trackables(tracker, trackable, tracker_details, hw_info, pos_report) + + 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: - tractive = hass.data[DOMAIN].pop(entry.entry_id) + tractive = hass.data[DOMAIN][entry.entry_id].pop(CLIENT) await tractive.unsubscribe() + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -142,7 +192,14 @@ class TractiveClient: continue def _send_hardware_update(self, event): - payload = {ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"]} + # Sometimes hardware event doesn't contain complete data. + payload = { + ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], + ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", + ATTR_LIVE_TRACKING: event.get("live_tracking", {}).get("active"), + ATTR_BUZZER: event.get("buzzer_control", {}).get("active"), + ATTR_LED: event.get("led_control", {}).get("active"), + } self._dispatch_tracker_event( TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload ) diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py new file mode 100644 index 00000000000..fd3a00c377d --- /dev/null +++ b/homeassistant/components/tractive/binary_sensor.py @@ -0,0 +1,96 @@ +"""Support for Tractive binary sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ATTR_BATTERY_CHARGING +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + CLIENT, + DOMAIN, + SERVER_UNAVAILABLE, + TRACKABLES, + TRACKER_HARDWARE_STATUS_UPDATED, +) +from .entity import TractiveEntity + +TRACKERS_WITH_BUILTIN_BATTERY = ("TRNJA4", "TRAXL1") + + +class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): + """Tractive sensor.""" + + def __init__(self, user_id, trackable, tracker_details, unique_id, description): + """Initialize sensor entity.""" + super().__init__(user_id, trackable, tracker_details) + + self._attr_name = f"{trackable['details']['name']} {description.name}" + self._attr_unique_id = unique_id + self.entity_description = description + + @callback + def handle_server_unavailable(self): + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def handle_hardware_status_update(self, event): + """Handle hardware status update.""" + self._attr_is_on = event[self.entity_description.key] + self._attr_available = True + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self.handle_hardware_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + +SENSOR_TYPE = BinarySensorEntityDescription( + key=ATTR_BATTERY_CHARGING, + name="Battery Charging", + device_class=DEVICE_CLASS_BATTERY_CHARGING, +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Tractive device trackers.""" + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + + entities = [] + + for item in trackables: + if item.tracker_details["model_number"] not in TRACKERS_WITH_BUILTIN_BATTERY: + continue + entities.append( + TractiveBinarySensor( + client.user_id, + item.trackable, + item.tracker_details, + f"{item.trackable['_id']}_{SENSOR_TYPE.key}", + SENSOR_TYPE, + ) + ) + + async_add_entities(entities) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index cb525d538e4..6a61024cd51 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -7,8 +7,14 @@ DOMAIN = "tractive" RECONNECT_INTERVAL = timedelta(seconds=10) ATTR_DAILY_GOAL = "daily_goal" +ATTR_BUZZER = "buzzer" +ATTR_LED = "led" +ATTR_LIVE_TRACKING = "live_tracking" ATTR_MINUTES_ACTIVE = "minutes_active" +CLIENT = "client" +TRACKABLES = "trackables" + TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index c1652c27b8f..1e35e41fc8a 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -1,6 +1,5 @@ """Support for Tractive device trackers.""" -import asyncio import logging from homeassistant.components.device_tracker import SOURCE_TYPE_GPS @@ -9,8 +8,10 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + CLIENT, DOMAIN, SERVER_UNAVAILABLE, + TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, ) @@ -21,31 +22,25 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id] + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] - trackables = await client.trackable_objects() + entities = [] - entities = await asyncio.gather( - *(create_trackable_entity(client, trackable) for trackable in trackables) - ) + for item in trackables: + entities.append( + TractiveDeviceTracker( + client.user_id, + item.trackable, + item.tracker_details, + item.hw_info, + item.pos_report, + ) + ) async_add_entities(entities) -async def create_trackable_entity(client, trackable): - """Create an entity instance.""" - trackable = await trackable.details() - tracker = client.tracker(trackable["device_id"]) - - tracker_details, hw_info, pos_report = await asyncio.gather( - tracker.details(), tracker.hw_info(), tracker.pos_report() - ) - - return TractiveDeviceTracker( - client.user_id, trackable, tracker_details, hw_info, pos_report - ) - - class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index ba2f330f894..9fd8ee6ac5f 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -1,7 +1,6 @@ """Support for Tractive sensors.""" from __future__ import annotations -import asyncio from dataclasses import dataclass from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -17,8 +16,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( ATTR_DAILY_GOAL, ATTR_MINUTES_ACTIVE, + CLIENT, DOMAIN, SERVER_UNAVAILABLE, + TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, ) @@ -137,29 +138,21 @@ SENSOR_TYPES = ( async def async_setup_entry(hass, entry, async_add_entities): """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id] - - trackables = await client.trackable_objects() + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [] - async def _prepare_sensor_entity(item): - """Prepare sensor entities.""" - trackable = await item.details() - tracker = client.tracker(trackable["device_id"]) - tracker_details = await tracker.details() + for item in trackables: for description in SENSOR_TYPES: - unique_id = f"{trackable['_id']}_{description.key}" entities.append( description.entity_class( client.user_id, - trackable, - tracker_details, - unique_id, + item.trackable, + item.tracker_details, + f"{item.trackable['_id']}_{description.key}", description, ) ) - await asyncio.gather(*(_prepare_sensor_entity(item) for item in trackables)) - async_add_entities(entities) diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py new file mode 100644 index 00000000000..d58e38a7cc9 --- /dev/null +++ b/homeassistant/components/tractive/switch.py @@ -0,0 +1,173 @@ +"""Support for Tractive switches.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any, Literal + +from aiotractive.exceptions import TractiveError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Trackables +from .const import ( + ATTR_BUZZER, + ATTR_LED, + ATTR_LIVE_TRACKING, + CLIENT, + DOMAIN, + SERVER_UNAVAILABLE, + TRACKABLES, + TRACKER_HARDWARE_STATUS_UPDATED, +) +from .entity import TractiveEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class TractiveRequiredKeysMixin: + """Mixin for required keys.""" + + method: Literal["async_set_buzzer", "async_set_led", "async_set_live_tracking"] + + +@dataclass +class TractiveSwitchEntityDescription( + SwitchEntityDescription, TractiveRequiredKeysMixin +): + """Class describing Tractive switch entities.""" + + +SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( + TractiveSwitchEntityDescription( + key=ATTR_BUZZER, + name="Tracker Buzzer", + icon="mdi:volume-high", + method="async_set_buzzer", + ), + TractiveSwitchEntityDescription( + key=ATTR_LED, + name="Tracker LED", + icon="mdi:led-on", + method="async_set_led", + ), + TractiveSwitchEntityDescription( + key=ATTR_LIVE_TRACKING, + name="Live Tracking", + icon="mdi:map-marker-path", + method="async_set_live_tracking", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tractive switches.""" + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + + entities = [ + TractiveSwitch(client.user_id, item, description) + for description in SWITCH_TYPES + for item in trackables + ] + + async_add_entities(entities) + + +class TractiveSwitch(TractiveEntity, SwitchEntity): + """Tractive switch.""" + + entity_description: TractiveSwitchEntityDescription + + def __init__( + self, + user_id: str, + item: Trackables, + description: TractiveSwitchEntityDescription, + ) -> None: + """Initialize switch entity.""" + super().__init__(user_id, item.trackable, item.tracker_details) + + self._attr_name = f"{item.trackable['details']['name']} {description.name}" + self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" + self._attr_available = False + self._tracker = item.tracker + self._method = getattr(self, description.method) + self.entity_description = description + + @callback + def handle_server_unavailable(self) -> None: + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def handle_hardware_status_update(self, event: dict[str, Any]) -> None: + """Handle hardware status update.""" + if (state := event[self.entity_description.key]) is None: + return + self._attr_is_on = state + self._attr_available = True + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self.handle_hardware_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on a switch.""" + try: + result = await self._method(True) + except TractiveError as error: + _LOGGER.error(error) + return + # Write state back to avoid switch flips with a slow response + if result["pending"]: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off a switch.""" + try: + result = await self._method(False) + except TractiveError as error: + _LOGGER.error(error) + return + # Write state back to avoid switch flips with a slow response + if result["pending"]: + self._attr_is_on = False + self.async_write_ha_state() + + async def async_set_buzzer(self, active: bool) -> dict[str, Any]: + """Set the buzzer on/off.""" + return await self._tracker.set_buzzer_active(active) + + async def async_set_led(self, active: bool) -> dict[str, Any]: + """Set the LED on/off.""" + return await self._tracker.set_led_active(active) + + async def async_set_live_tracking(self, active: bool) -> dict[str, Any]: + """Set the live tracking on/off.""" + return await self._tracker.set_live_tracking_active(active) diff --git a/homeassistant/components/tractive/translations/el.json b/homeassistant/components/tractive/translations/el.json new file mode 100644 index 00000000000..15ba157f55c --- /dev/null +++ b/homeassistant/components/tractive/translations/el.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "reauth_failed_existing": "\u0394\u03b5\u03bd \u03ae\u03c4\u03b1\u03bd \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03b1\u03c7\u03ce\u03c1\u03b7\u03c3\u03b7\u03c2 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7\u03c2, \u03b1\u03c6\u03b1\u03b9\u03c1\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03ba\u03b1\u03b9 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03be\u03b1\u03bd\u03ac." + }, + "step": { + "user": { + "data": { + "email": "\u03b7\u03bb\u03b5\u03ba\u03c4\u03c1\u03bf\u03bd\u03b9\u03ba\u03cc \u03c4\u03b1\u03c7\u03c5\u03b4\u03c1\u03bf\u03bc\u03b5\u03af\u03bf" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/es.json b/homeassistant/components/tractive/translations/es.json index 11aa4f1aa9c..9b252a0b2f0 100644 --- a/homeassistant/components/tractive/translations/es.json +++ b/homeassistant/components/tractive/translations/es.json @@ -1,17 +1,18 @@ { "config": { "abort": { - "already_configured": "El sistema ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { - "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", - "unknown": "Error desconocido" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "user": { "data": { - "email": "Correo-e", - "password": "Clave" + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" } } } diff --git a/homeassistant/components/tractive/translations/fr.json b/homeassistant/components/tractive/translations/fr.json index 1d3c15c13d5..7e53cba0d74 100644 --- a/homeassistant/components/tractive/translations/fr.json +++ b/homeassistant/components/tractive/translations/fr.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Dispositif d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_failed_existing": "Impossible de mettre \u00e0 jour l'entr\u00e9e de configuration, veuillez supprimer l'int\u00e9gration et la configurer \u00e0 nouveau.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification invalide", @@ -10,7 +12,7 @@ "step": { "user": { "data": { - "email": "Adresse mail", + "email": "Email", "password": "Mot de passe" } } diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 2c113b63727..4f5997b2fa1 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -1,7 +1,7 @@ """Support for IKEA Tradfri.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Any @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import Event, async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -97,7 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: psk=entry.data[CONF_KEY], ) - async def on_hass_stop(event): + async def on_hass_stop(event: Event) -> None: """Close connection when hass stops.""" await factory.shutdown() @@ -135,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - async def async_keep_alive(now): + async def async_keep_alive(now: datetime) -> None: if hass.is_stopping: return @@ -151,7 +151,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: diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 0c9f2f7312f..b0679a2a8ce 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -1,22 +1,37 @@ """Base class for IKEA TRADFRI.""" +from __future__ import annotations + +from collections.abc import Callable from functools import wraps import logging +from typing import Any +from pytradfri.command import Command +from pytradfri.device import Device +from pytradfri.device.blind import Blind +from pytradfri.device.blind_control import BlindControl +from pytradfri.device.light import Light +from pytradfri.device.light_control import LightControl +from pytradfri.device.signal_repeater_control import SignalRepeaterControl +from pytradfri.device.socket import Socket +from pytradfri.device.socket_control import SocketControl from pytradfri.error import PytradfriError from homeassistant.core import callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def handle_error(func): +def handle_error( + func: Callable[[Command | list[Command]], Any] +) -> Callable[[str], Any]: """Handle tradfri api call error.""" @wraps(func) - async def wrapper(command): + async def wrapper(command: Command | list[Command]) -> None: """Decorate api call.""" try: await func(command) @@ -32,24 +47,30 @@ class TradfriBaseClass(Entity): All devices and groups should ultimately inherit from this class. """ - def __init__(self, device, api, gateway_id): + _attr_should_poll = False + + def __init__( + self, + device: Device, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a device.""" self._api = handle_error(api) - self._device = None - self._device_control = None - self._device_data = None + self._device: Device = device + self._device_control: BlindControl | LightControl | SocketControl | SignalRepeaterControl | None = ( + None + ) + self._device_data: Socket | Light | Blind | None = None self._gateway_id = gateway_id - self._name = None - self._unique_id = None - self._refresh(device) @callback - def _async_start_observe(self, exc=None): + def _async_start_observe(self, exc: Exception | None = None) -> None: """Start observation of device.""" if exc: self.async_write_ha_state() - _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) + _LOGGER.warning("Observation failed for %s", self._attr_name, exc_info=exc) try: cmd = self._device.observe( @@ -62,35 +83,20 @@ class TradfriBaseClass(Entity): _LOGGER.warning("Observation failed, trying again", exc_info=err) self._async_start_observe() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Start thread when added to hass.""" self._async_start_observe() - @property - def name(self): - """Return the display name of this device.""" - return self._name - - @property - def should_poll(self): - """No polling needed for tradfri device.""" - return False - - @property - def unique_id(self): - """Return unique ID for device.""" - return self._unique_id - @callback - def _observe_update(self, device): + def _observe_update(self, device: Device) -> None: """Receive new state data for this device.""" self._refresh(device) self.async_write_ha_state() - def _refresh(self, device): + def _refresh(self, device: Device) -> None: """Refresh the device data.""" self._device = device - self._name = device.name + self._attr_name = device.name class TradfriBaseDevice(TradfriBaseClass): @@ -99,31 +105,20 @@ class TradfriBaseDevice(TradfriBaseClass): All devices should inherit from this class. """ - def __init__(self, device, api, gateway_id): - """Initialize a device.""" - super().__init__(device, api, gateway_id) - self._available = True - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" info = self._device.device_info - return { "identifiers": {(DOMAIN, self._device.id)}, "manufacturer": info.manufacturer, "model": info.model_number, - "name": self._name, + "name": self._attr_name, "sw_version": info.firmware_version, "via_device": (DOMAIN, self._gateway_id), } - def _refresh(self, device): + def _refresh(self, device: Device) -> None: """Refresh the device data.""" super()._refresh(device) - self._available = device.reachable + self._attr_available = device.reachable diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 1a6ae8706f2..e45bd36753f 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Tradfri.""" +from __future__ import annotations + import asyncio +from typing import Any from uuid import uuid4 import async_timeout @@ -8,6 +11,9 @@ from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_GATEWAY_ID, @@ -23,7 +29,7 @@ from .const import ( class AuthError(Exception): """Exception if authentication occurs.""" - def __init__(self, code): + def __init__(self, code: str) -> None: """Initialize exception.""" super().__init__() self.code = code @@ -34,18 +40,22 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" self._host = None self._import_groups = False - 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.""" return await self.async_step_auth() - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the authentication with a gateway.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: host = user_input.get(CONF_HOST, self._host) @@ -82,7 +92,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="auth", data_schema=vol.Schema(fields), errors=errors ) - async def async_step_homekit(self, discovery_info): + async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle homekit discovery.""" await self.async_set_unique_id(discovery_info["properties"]["id"]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) @@ -104,7 +114,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = host return await self.async_step_auth() - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import a config entry.""" self._async_abort_entries_match({CONF_HOST: user_input["host"]}) @@ -131,7 +141,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = user_input["host"] return await self.async_step_auth() - async def _entry_from_data(self, data): + async def _entry_from_data(self, data: dict[str, Any]) -> FlowResult: """Create an entry from data.""" host = data[CONF_HOST] gateway_id = data[CONF_GATEWAY_ID] @@ -154,7 +164,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=host, data=data) -async def authenticate(hass, host, security_code): +async def authenticate( + hass: HomeAssistant, host: str, security_code: str +) -> dict[str, str | bool]: """Authenticate with a Tradfri hub.""" identity = uuid4().hex @@ -174,7 +186,9 @@ async def authenticate(hass, host, security_code): return await get_gateway_info(hass, host, identity, key) -async def get_gateway_info(hass, host, identity, key): +async def get_gateway_info( + hass: HomeAssistant, host: str, identity: str, key: str +) -> dict[str, str | bool]: """Return info for the gateway.""" try: diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 4c7cde1dfd1..7bcbf5af5e1 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -1,12 +1,25 @@ """Support for IKEA Tradfri covers.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +from pytradfri.command import Command from homeassistant.components.cover import ATTR_POSITION, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseDevice from .const import ATTR_MODEL, CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API -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: """Load Tradfri covers based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -21,48 +34,63 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TradfriCover(TradfriBaseDevice, CoverEntity): """The platform class required by Home Assistant.""" - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a cover.""" super().__init__(device, api, gateway_id) - self._unique_id = f"{gateway_id}-{device.id}" + self._attr_unique_id = f"{gateway_id}-{device.id}" self._refresh(device) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" return {ATTR_MODEL: self._device.device_info.model_number} @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. """ - return 100 - self._device_data.current_cover_position + if not self._device_data: + return None + return 100 - cast(int, self._device_data.current_cover_position) - 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.""" + if not self._device_control: + return await self._api(self._device_control.set_state(100 - kwargs[ATTR_POSITION])) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + if not self._device_control: + return await self._api(self._device_control.set_state(0)) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" + if not self._device_control: + return await self._api(self._device_control.set_state(100)) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Close cover.""" + if not self._device_control: + return await self._api(self._device_control.trigger_blind()) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed or not.""" return self.current_cover_position == 0 - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the cover data.""" super()._refresh(device) self._device = device diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a4c2ee67865..c41bc55bcc8 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -1,4 +1,11 @@ """Support for IKEA Tradfri lights.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +from pytradfri.command import Command + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -9,6 +16,9 @@ from homeassistant.components.light import ( SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util from .base_class import TradfriBaseClass, TradfriBaseDevice @@ -28,7 +38,11 @@ from .const import ( ) -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: """Load Tradfri lights based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -48,20 +62,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TradfriGroup(TradfriBaseClass, LightEntity): """The platform class for light groups required by hass.""" - def __init__(self, device, api, gateway_id): + _attr_supported_features = SUPPORTED_GROUP_FEATURES + + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a Group.""" super().__init__(device, api, gateway_id) - self._unique_id = f"group-{gateway_id}-{device.id}" - + self._attr_unique_id = f"group-{gateway_id}-{device.id}" + self._attr_should_poll = True self._refresh(device) - @property - def should_poll(self): - """Poll needed for tradfri groups.""" - return True - - async def async_update(self): + async def async_update(self) -> None: """Fetch new state data for the group. This method is required for groups to update properly. @@ -69,25 +85,20 @@ class TradfriGroup(TradfriBaseClass, LightEntity): await self._api(self._device.update()) @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_GROUP_FEATURES - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if group lights are on.""" - return self._device.state + return cast(bool, self._device.state) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of the group lights.""" - return self._device.dimmer + return cast(int, self._device.dimmer) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the group lights to turn off.""" await self._api(self._device.set_state(0)) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the group lights to turn on, or dim.""" keys = {} if ATTR_TRANSITION in kwargs: @@ -105,10 +116,15 @@ class TradfriGroup(TradfriBaseClass, LightEntity): class TradfriLight(TradfriBaseDevice, LightEntity): """The platform class required by Home Assistant.""" - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a Light.""" super().__init__(device, api, gateway_id) - self._unique_id = f"light-{gateway_id}-{device.id}" + self._attr_unique_id = f"light-{gateway_id}-{device.id}" self._hs_color = None # Calculate supported features @@ -119,54 +135,53 @@ class TradfriLight(TradfriBaseDevice, LightEntity): _features |= SUPPORT_COLOR | SUPPORT_COLOR_TEMP if device.light_control.can_set_temp: _features |= SUPPORT_COLOR_TEMP - self._features = _features + self._attr_supported_features = _features self._refresh(device) + if self._device_control: + self._attr_min_mireds = self._device_control.min_mireds + self._attr_max_mireds = self._device_control.max_mireds @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self._device_control.min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self._device_control.max_mireds - - @property - def supported_features(self): - """Flag supported features.""" - return self._features - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" - return self._device_data.state + if not self._device_data: + return False + return cast(bool, self._device_data.state) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of the light.""" - return self._device_data.dimmer + if not self._device_data: + return None + return cast(int, self._device_data.dimmer) @property - def color_temp(self): + def color_temp(self) -> int | None: """Return the color temp value in mireds.""" - return self._device_data.color_temp + if not self._device_data: + return None + return cast(int, self._device_data.color_temp) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """HS color of the light.""" + if not self._device_control or not self._device_data: + return None if self._device_control.can_set_color: hsbxy = self._device_data.hsb_xy_color hue = hsbxy[0] / (self._device_control.max_hue / 360) sat = hsbxy[1] / (self._device_control.max_saturation / 100) if hue is not None and sat is not None: return hue, sat + return None - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" # This allows transitioning to off, but resets the brightness # to 1 for the next set_state(True) command + if not self._device_control: + return transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 @@ -176,8 +191,10 @@ class TradfriLight(TradfriBaseDevice, LightEntity): else: await self._api(self._device_control.set_state(False)) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" + if not self._device_control: + return transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 @@ -256,7 +273,7 @@ class TradfriLight(TradfriBaseDevice, LightEntity): if command is not None: await self._api(command) - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the light data.""" super()._refresh(device) diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index f7f68b666ba..f761aba5ddd 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -1,13 +1,26 @@ """Support for IKEA Tradfri sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +from pytradfri.command import Command from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseDevice from .const import CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API -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 a Tradfri config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -32,12 +45,19 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize the device.""" super().__init__(device, api, gateway_id) - self._unique_id = f"{gateway_id}-{device.id}" + self._attr_unique_id = f"{gateway_id}-{device.id}" @property - def native_value(self): + def native_value(self) -> int | None: """Return the current state of the device.""" - return self._device.device_info.battery_level + if not self._device: + return None + return cast(int, self._device.device_info.battery_level) diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 6634090d00d..b7051989265 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -1,11 +1,25 @@ """Support for IKEA Tradfri switches.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +from pytradfri.command import Command + 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 .base_class import TradfriBaseDevice from .const import CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API -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: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] tradfri_data = hass.data[DOMAIN][config_entry.entry_id] @@ -22,12 +36,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TradfriSwitch(TradfriBaseDevice, SwitchEntity): """The platform class required by Home Assistant.""" - def __init__(self, device, api, gateway_id): + def __init__( + self, + device: Command, + api: Callable[[Command | list[Command]], Any], + gateway_id: str, + ) -> None: """Initialize a switch.""" super().__init__(device, api, gateway_id) - self._unique_id = f"{gateway_id}-{device.id}" + self._attr_unique_id = f"{gateway_id}-{device.id}" - def _refresh(self, device): + def _refresh(self, device: Command) -> None: """Refresh the switch data.""" super()._refresh(device) @@ -36,14 +55,20 @@ class TradfriSwitch(TradfriBaseDevice, SwitchEntity): self._device_data = device.socket_control.sockets[0] @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" - return self._device_data.state + if not self._device_data: + return False + return cast(bool, self._device_data.state) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" + if not self._device_control: + return None await self._api(self._device_control.set_state(False)) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" + if not self._device_control: + return None await self._api(self._device_control.set_state(True)) diff --git a/homeassistant/components/tradfri/translations/fi.json b/homeassistant/components/tradfri/translations/fi.json index 31984784ee6..4946d88778f 100644 --- a/homeassistant/components/tradfri/translations/fi.json +++ b/homeassistant/components/tradfri/translations/fi.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Silta on jo m\u00e4\u00e4ritetty" }, + "error": { + "cannot_connect": "Yhdist\u00e4minen ep\u00e4onnistui" + }, "step": { "auth": { "data": { diff --git a/homeassistant/components/tradfri/translations/fr.json b/homeassistant/components/tradfri/translations/fr.json index 92d327be951..2d32029c954 100644 --- a/homeassistant/components/tradfri/translations/fr.json +++ b/homeassistant/components/tradfri/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Le pont est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "La configuration du pont est d\u00e9j\u00e0 en cours." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 la passerelle.", + "cannot_connect": "\u00c9chec de connexion", "invalid_key": "\u00c9chec de l'enregistrement avec la cl\u00e9 fournie. Si cela se reproduit, essayez de red\u00e9marrer la passerelle.", "timeout": "D\u00e9lai d'attente de la validation du code expir\u00e9" }, "step": { "auth": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "security_code": "Code de s\u00e9curit\u00e9" }, "description": "Vous pouvez trouver le code de s\u00e9curit\u00e9 au dos de votre passerelle.", diff --git a/homeassistant/components/tradfri/translations/hu.json b/homeassistant/components/tradfri/translations/hu.json index 3bc4ec90e77..e5f749a83df 100644 --- a/homeassistant/components/tradfri/translations/hu.json +++ b/homeassistant/components/tradfri/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van." + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -12,11 +12,11 @@ "step": { "auth": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "security_code": "Biztons\u00e1gi K\u00f3d" }, "description": "A biztons\u00e1gi k\u00f3dot a Gatewayed h\u00e1toldal\u00e1n tal\u00e1lod.", - "title": "Add meg a biztons\u00e1gi k\u00f3dot" + "title": "Adja meg a biztons\u00e1gi k\u00f3dot" } } } diff --git a/homeassistant/components/transmission/translations/fr.json b/homeassistant/components/transmission/translations/fr.json index 45ad7968bcb..64efb47c8c3 100644 --- a/homeassistant/components/transmission/translations/fr.json +++ b/homeassistant/components/transmission/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" }, diff --git a/homeassistant/components/transmission/translations/hu.json b/homeassistant/components/transmission/translations/hu.json index 5c968b21ed7..5e3dcfd2b6c 100644 --- a/homeassistant/components/transmission/translations/hu.json +++ b/homeassistant/components/transmission/translations/hu.json @@ -6,12 +6,12 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + "name_exists": "A n\u00e9v m\u00e1r foglalt" }, "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index c6502bf0850..340edcb6626 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.21.1"], + "requirements": ["numpy==1.21.2"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index be38eb6ec09..59dfaf484b4 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import functools as ft import hashlib +from http import HTTPStatus import io import logging import mimetypes @@ -29,7 +30,6 @@ from homeassistant.const import ( CONF_DESCRIPTION, CONF_NAME, CONF_PLATFORM, - HTTP_BAD_REQUEST, HTTP_NOT_FOUND, PLATFORM_FORMAT, ) @@ -598,10 +598,10 @@ class TextToSpeechUrlView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): return self.json_message( - "Must specify platform and message", HTTP_BAD_REQUEST + "Must specify platform and message", HTTPStatus.BAD_REQUEST ) p_type = data[ATTR_PLATFORM] @@ -616,7 +616,7 @@ class TextToSpeechUrlView(HomeAssistantView): ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) - return self.json({"error": err}, HTTP_BAD_REQUEST) + return self.json({"error": err}, HTTPStatus.BAD_REQUEST) base = self.tts.base_url or get_url(self.tts.hass) url = base + path diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 7a639665948..28c43d8df46 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,376 +1,222 @@ """Support for Tuya Smart devices.""" -from datetime import timedelta + +import itertools import logging -from tuyaha import TuyaApi -from tuyaha.tuyaapi import ( - TuyaAPIException, - TuyaAPIRateLimitException, - TuyaFrequentlyInvokeException, - TuyaNetException, - TuyaServerException, +from tuya_iot import ( + AuthType, + TuyaDevice, + TuyaDeviceListener, + TuyaDeviceManager, + TuyaHomeManager, + TuyaOpenAPI, + TuyaOpenMQ, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_PLATFORM, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( - CONF_COUNTRYCODE, - CONF_DISCOVERY_INTERVAL, - CONF_QUERY_DEVICE, - CONF_QUERY_INTERVAL, - DEFAULT_DISCOVERY_INTERVAL, - DEFAULT_QUERY_INTERVAL, + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + CONF_PASSWORD, + CONF_PROJECT_TYPE, + CONF_USERNAME, DOMAIN, - SIGNAL_CONFIG_ENTITY, - SIGNAL_DELETE_ENTITY, - SIGNAL_UPDATE_ENTITY, - TUYA_DATA, - TUYA_DEVICES_CONF, + PLATFORMS, + TUYA_DEVICE_MANAGER, TUYA_DISCOVERY_NEW, - TUYA_PLATFORMS, - TUYA_TYPE_NOT_QUERY, + TUYA_HA_DEVICES, + TUYA_HA_SIGNAL_UPDATE_ENTITY, + TUYA_HA_TUYA_MAP, + TUYA_HOME_MANAGER, + TUYA_MQTT_LISTENER, ) _LOGGER = logging.getLogger(__name__) -ATTR_TUYA_DEV_ID = "tuya_device_id" -ENTRY_IS_SETUP = "tuya_entry_is_setup" - -SERVICE_FORCE_UPDATE = "force_update" -SERVICE_PULL_DEVICES = "pull_devices" - -TUYA_TYPE_TO_HA = { - "climate": "climate", - "cover": "cover", - "fan": "fan", - "light": "light", - "scene": "scene", - "switch": "switch", -} - -TUYA_TRACKER = "tuya_tracker" -STOP_CANCEL = "stop_event_cancel" - -CONFIG_SCHEMA = cv.deprecated(DOMAIN) - - -def _update_discovery_interval(hass, interval): - tuya = hass.data[DOMAIN].get(TUYA_DATA) - if not tuya: - return - - try: - tuya.discovery_interval = interval - _LOGGER.info("Tuya discovery device poll interval set to %s seconds", interval) - except ValueError as ex: - _LOGGER.warning(ex) - - -def _update_query_interval(hass, interval): - tuya = hass.data[DOMAIN].get(TUYA_DATA) - if not tuya: - return - - try: - tuya.query_interval = interval - _LOGGER.info("Tuya query device poll interval set to %s seconds", interval) - except ValueError as ex: - _LOGGER.warning(ex) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Tuya platform.""" - - tuya = TuyaApi() - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - country_code = entry.data[CONF_COUNTRYCODE] - platform = entry.data[CONF_PLATFORM] - - try: - await hass.async_add_executor_job( - tuya.init, username, password, country_code, platform - ) - except ( - TuyaNetException, - TuyaServerException, - TuyaFrequentlyInvokeException, - ) as exc: - raise ConfigEntryNotReady() from exc - - except TuyaAPIRateLimitException as exc: - raise ConfigEntryNotReady("Tuya login rate limited") from exc - - except TuyaAPIException as exc: - _LOGGER.error( - "Connection error during integration setup. Error: %s", - exc, - ) - return False - - domain_data = hass.data[DOMAIN] = { - TUYA_DATA: tuya, - TUYA_DEVICES_CONF: entry.options.copy(), - TUYA_TRACKER: None, - ENTRY_IS_SETUP: set(), - "entities": {}, - "pending": {}, - "listener": entry.add_update_listener(update_listener), + """Async setup hass config entry.""" + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + TUYA_HA_TUYA_MAP: {}, + TUYA_HA_DEVICES: set(), } - _update_discovery_interval( - hass, entry.options.get(CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL) + # Project type has been renamed to auth type in the upstream Tuya IoT SDK. + # This migrates existing config entries to reflect that name change. + if CONF_PROJECT_TYPE in entry.data: + data = {**entry.data, CONF_AUTH_TYPE: entry.data[CONF_PROJECT_TYPE]} + data.pop(CONF_PROJECT_TYPE) + hass.config_entries.async_update_entry(entry, data=data) + + success = await _init_tuya_sdk(hass, entry) + + if not success: + hass.data[DOMAIN].pop(entry.entry_id) + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return bool(success) + + +async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool: + auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) + api = TuyaOpenAPI( + endpoint=entry.data[CONF_ENDPOINT], + access_id=entry.data[CONF_ACCESS_ID], + access_secret=entry.data[CONF_ACCESS_SECRET], + auth_type=auth_type, ) - _update_query_interval( - hass, entry.options.get(CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL) - ) + api.set_dev_channel("hass") - async def async_load_devices(device_list): - """Load new devices by device_list.""" - device_type_list = {} - for device in device_list: - dev_type = device.device_type() - if ( - dev_type in TUYA_TYPE_TO_HA - and device.object_id() not in domain_data["entities"] - ): - ha_type = TUYA_TYPE_TO_HA[dev_type] - if ha_type not in device_type_list: - device_type_list[ha_type] = [] - device_type_list[ha_type].append(device.object_id()) - domain_data["entities"][device.object_id()] = None + if auth_type == AuthType.CUSTOM: + response = await hass.async_add_executor_job( + api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + else: + response = await hass.async_add_executor_job( + api.connect, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTRY_CODE], + entry.data[CONF_APP_TYPE], + ) - for ha_type, dev_ids in device_type_list.items(): - config_entries_key = f"{ha_type}.tuya" - if config_entries_key not in domain_data[ENTRY_IS_SETUP]: - domain_data["pending"][ha_type] = dev_ids - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, ha_type) - ) - domain_data[ENTRY_IS_SETUP].add(config_entries_key) - else: - async_dispatcher_send(hass, TUYA_DISCOVERY_NEW.format(ha_type), dev_ids) + if response.get("success", False) is False: + _LOGGER.error("Tuya login error response: %s", response) + return False - await async_load_devices(tuya.get_all_devices()) + tuya_mq = TuyaOpenMQ(api) + tuya_mq.start() - def _get_updated_devices(): - try: - tuya.poll_devices_update() - except TuyaFrequentlyInvokeException as exc: - _LOGGER.error(exc) - return tuya.get_all_devices() + device_manager = TuyaDeviceManager(api, tuya_mq) - async def async_poll_devices_update(event_time): - """Check if accesstoken is expired and pull device list from server.""" - _LOGGER.debug("Pull devices from Tuya") - # Add new discover device. - device_list = await hass.async_add_executor_job(_get_updated_devices) - await async_load_devices(device_list) - # Delete not exist device. - newlist_ids = [] - for device in device_list: - newlist_ids.append(device.object_id()) - for dev_id in list(domain_data["entities"]): - if dev_id not in newlist_ids: - async_dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) - domain_data["entities"].pop(dev_id) + # Get device list + home_manager = TuyaHomeManager(api, tuya_mq, device_manager) + await hass.async_add_executor_job(home_manager.update_device_cache) + hass.data[DOMAIN][entry.entry_id][TUYA_HOME_MANAGER] = home_manager - domain_data[TUYA_TRACKER] = async_track_time_interval( - hass, async_poll_devices_update, timedelta(minutes=2) - ) + listener = DeviceListener(hass, entry) + hass.data[DOMAIN][entry.entry_id][TUYA_MQTT_LISTENER] = listener + device_manager.add_device_listener(listener) + hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] = device_manager - @callback - def _async_cancel_tuya_tracker(event): - domain_data[TUYA_TRACKER]() # pylint: disable=not-callable + # Clean up device entities + await cleanup_device_registry(hass, entry) - domain_data[STOP_CANCEL] = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _async_cancel_tuya_tracker - ) + _LOGGER.debug("init support type->%s", PLATFORMS) - hass.services.async_register( - DOMAIN, SERVICE_PULL_DEVICES, async_poll_devices_update - ) - - async def async_force_update(call): - """Force all devices to pull data.""" - async_dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) - - hass.services.async_register(DOMAIN, SERVICE_FORCE_UPDATE, async_force_update) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def cleanup_device_registry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove deleted device registry entry if there are no remaining entities.""" + + device_registry_object = device_registry.async_get(hass) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + + for dev_id, device_entry in list(device_registry_object.devices.items()): + for item in device_entry.identifiers: + if DOMAIN == item[0] and item[1] not in device_manager.device_map: + device_registry_object.async_remove_device(dev_id) + break + + +@callback +def async_remove_hass_device(hass: HomeAssistant, device_id: str) -> None: + """Remove device from hass cache.""" + device_registry_object = device_registry.async_get(hass) + for device_entry in list(device_registry_object.devices.values()): + if device_id in list(device_entry.identifiers)[0]: + device_registry_object.async_remove_device(device_entry.id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" - domain_data = hass.data[DOMAIN] - platforms = [platform.split(".", 1)[0] for platform in domain_data[ENTRY_IS_SETUP]] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) - if unload_ok: - domain_data["listener"]() - domain_data[STOP_CANCEL]() - domain_data[TUYA_TRACKER]() - hass.services.async_remove(DOMAIN, SERVICE_FORCE_UPDATE) - hass.services.async_remove(DOMAIN, SERVICE_PULL_DEVICES) - hass.data.pop(DOMAIN) + _LOGGER.debug("integration unload") + unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload: + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_manager.mq.stop() + device_manager.remove_device_listener( + hass.data[DOMAIN][entry.entry_id][TUYA_MQTT_LISTENER] + ) - return unload_ok + hass.data[DOMAIN].pop(entry.entry_id) + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): - """Update when config_entry options update.""" - hass.data[DOMAIN][TUYA_DEVICES_CONF] = entry.options.copy() - _update_discovery_interval( - hass, entry.options.get(CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL) - ) - _update_query_interval( - hass, entry.options.get(CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL) - ) - async_dispatcher_send(hass, SIGNAL_CONFIG_ENTITY) +class DeviceListener(TuyaDeviceListener): + """Device Update Listener.""" + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Init DeviceListener.""" -async def cleanup_device_registry(hass: HomeAssistant, device_id): - """Remove device registry entry if there are no remaining entities.""" + self.hass = hass + self.entry = entry - device_registry = await hass.helpers.device_registry.async_get_registry() - entity_registry = await hass.helpers.entity_registry.async_get_registry() - if device_id and not hass.helpers.entity_registry.async_entries_for_device( - entity_registry, device_id, include_disabled_entities=True - ): - device_registry.async_remove_device(device_id) - - -class TuyaDevice(Entity): - """Tuya base device.""" - - _dev_can_query_count = 0 - - def __init__(self, tuya, platform): - """Init Tuya devices.""" - self._tuya = tuya - self._tuya_platform = platform - - def _device_can_query(self): - """Check if device can also use query method.""" - dev_type = self._tuya.device_type() - return dev_type not in TUYA_TYPE_NOT_QUERY - - def _inc_device_count(self): - """Increment static variable device count.""" - if not self._device_can_query(): - return - TuyaDevice._dev_can_query_count += 1 - - def _dec_device_count(self): - """Decrement static variable device count.""" - if not self._device_can_query(): - return - TuyaDevice._dev_can_query_count -= 1 - - def _get_device_config(self): - """Get updated device options.""" - devices_config = self.hass.data[DOMAIN].get(TUYA_DEVICES_CONF) - if not devices_config: - return {} - dev_conf = devices_config.get(self.object_id, {}) - if dev_conf: + def update_device(self, device: TuyaDevice) -> None: + """Update device status.""" + if device.id in self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_DEVICES]: _LOGGER.debug( - "Configuration for deviceID %s: %s", self.object_id, str(dev_conf) + "_update-->%s;->>%s", + self, + device.id, ) - return dev_conf + dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[DOMAIN]["entities"][self.object_id] = self.entity_id - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - ) - self._inc_device_count() + def add_device(self, device: TuyaDevice) -> None: + """Add device added listener.""" + device_add = False - async def async_will_remove_from_hass(self): - """Call when entity is removed from hass.""" - self._dec_device_count() + if device.category in itertools.chain( + *self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_TUYA_MAP].values() + ): + ha_tuya_map = self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_TUYA_MAP] + self.hass.add_job(async_remove_hass_device, self.hass, device.id) - @property - def object_id(self): - """Return Tuya device id.""" - return self._tuya.object_id() + for domain, tuya_list in ha_tuya_map.items(): + if device.category in tuya_list: + device_add = True + _LOGGER.debug( + "Add device category->%s; domain-> %s", + device.category, + domain, + ) + self.hass.data[DOMAIN][self.entry.entry_id][TUYA_HA_DEVICES].add( + device.id + ) + dispatcher_send( + self.hass, TUYA_DISCOVERY_NEW.format(domain), [device.id] + ) - @property - def unique_id(self): - """Return a unique ID.""" - return f"tuya.{self._tuya.object_id()}" + if device_add: + device_manager = self.hass.data[DOMAIN][self.entry.entry_id][ + TUYA_DEVICE_MANAGER + ] + device_manager.mq.stop() + tuya_mq = TuyaOpenMQ(device_manager.api) + tuya_mq.start() - @property - def name(self): - """Return Tuya device name.""" - return self._tuya.name() + device_manager.mq = tuya_mq + tuya_mq.add_message_listener(device_manager.on_message) - @property - def available(self): - """Return if the device is available.""" - return self._tuya.available() - - @property - def device_info(self): - """Return a device description for device registry.""" - _device_info = { - "identifiers": {(DOMAIN, f"{self.unique_id}")}, - "manufacturer": TUYA_PLATFORMS.get( - self._tuya_platform, self._tuya_platform - ), - "name": self.name, - "model": self._tuya.object_type(), - } - return _device_info - - def update(self): - """Refresh Tuya device data.""" - query_dev = self.hass.data[DOMAIN][TUYA_DEVICES_CONF].get(CONF_QUERY_DEVICE, "") - use_discovery = ( - TuyaDevice._dev_can_query_count > 1 and self.object_id != query_dev - ) - try: - self._tuya.update(use_discovery=use_discovery) - except TuyaFrequentlyInvokeException as exc: - _LOGGER.error(exc) - - async def _delete_callback(self, dev_id): - """Remove this entity.""" - if dev_id == self.object_id: - entity_registry = ( - await self.hass.helpers.entity_registry.async_get_registry() - ) - if entity_registry.async_is_registered(self.entity_id): - entity_entry = entity_registry.async_get(self.entity_id) - entity_registry.async_remove(self.entity_id) - await cleanup_device_registry(self.hass, entity_entry.device_id) - else: - await self.async_remove(force_remove=True) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) + def remove_device(self, device_id: str) -> None: + """Add device removed listener.""" + _LOGGER.debug("tuya remove device:%s", device_id) + self.hass.add_job(async_remove_hass_device, self.hass, device_id) diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py new file mode 100644 index 00000000000..a1f65227e95 --- /dev/null +++ b/homeassistant/components/tuya/base.py @@ -0,0 +1,73 @@ +"""Tuya Home Assistant Base Device Model.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, TUYA_HA_SIGNAL_UPDATE_ENTITY + + +class TuyaHaEntity(Entity): + """Tuya base device.""" + + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init TuyaHaEntity.""" + super().__init__() + + self.tuya_device = device + self.tuya_device_manager = device_manager + + @staticmethod + def remap(old_value, old_min, old_max, new_min, new_max): + """Remap old_value to new_value.""" + return ((old_value - old_min) / (old_max - old_min)) * ( + new_max - new_min + ) + new_min + + @property + def should_poll(self) -> bool: + """Hass should not poll.""" + return False + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"tuya.{self.tuya_device.id}" + + @property + def name(self) -> str | None: + """Return Tuya device name.""" + return self.tuya_device.name + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, f"{self.tuya_device.id}")}, + "manufacturer": "Tuya", + "name": self.tuya_device.name, + "model": self.tuya_device.product_name, + } + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.tuya_device.online + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.tuya_device.id}", + self.async_write_ha_state, + ) + ) + + def _send_command(self, commands: list[dict[str, Any]]) -> None: + """Send command to the device.""" + self.tuya_device_manager.send_commands(self.tuya_device.id, commands) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 73ba69da797..810e8ad8aab 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -1,261 +1,484 @@ -"""Support for the Tuya climate devices.""" -from datetime import timedelta +"""Support for Tuya Climate.""" -from homeassistant.components.climate import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - ClimateEntity, -) +from __future__ import annotations + +import json +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.climate import DOMAIN as DEVICE_DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, HVAC_MODE_AUTO, HVAC_MODE_COOL, + HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_OFF, SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +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 . import TuyaDevice +from .base import TuyaHaEntity from .const import ( - CONF_CURR_TEMP_DIVIDER, - CONF_MAX_TEMP, - CONF_MIN_TEMP, - CONF_SET_TEMP_DIVIDED, - CONF_TEMP_DIVIDER, - CONF_TEMP_STEP_OVERRIDE, DOMAIN, - SIGNAL_CONFIG_ENTITY, - TUYA_DATA, + TUYA_DEVICE_MANAGER, TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, ) -DEVICE_TYPE = "climate" +_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=15) -HA_STATE_TO_TUYA = { - HVAC_MODE_AUTO: "auto", - HVAC_MODE_COOL: "cold", - HVAC_MODE_FAN_ONLY: "wind", - HVAC_MODE_HEAT: "hot", +# Air Conditioner +# https://developer.tuya.com/en/docs/iot/f?id=K9gf46qujdmwb +DPCODE_SWITCH = "switch" +DPCODE_TEMP_SET = "temp_set" +DPCODE_TEMP_SET_F = "temp_set_f" +DPCODE_MODE = "mode" +DPCODE_HUMIDITY_SET = "humidity_set" +DPCODE_FAN_SPEED_ENUM = "fan_speed_enum" + +# Temperature unit +DPCODE_TEMP_UNIT_CONVERT = "temp_unit_convert" +DPCODE_C_F = "c_f" + +# swing flap switch +DPCODE_SWITCH_HORIZONTAL = "switch_horizontal" +DPCODE_SWITCH_VERTICAL = "switch_vertical" + +# status +DPCODE_TEMP_CURRENT = "temp_current" +DPCODE_TEMP_CURRENT_F = "temp_current_f" +DPCODE_HUMIDITY_CURRENT = "humidity_current" + +SWING_OFF = "swing_off" +SWING_VERTICAL = "swing_vertical" +SWING_HORIZONTAL = "swing_horizontal" +SWING_BOTH = "swing_both" + +DEFAULT_MIN_TEMP = 7 +DEFAULT_MAX_TEMP = 35 + +TUYA_HVAC_TO_HA = { + "hot": HVAC_MODE_HEAT, + "cold": HVAC_MODE_COOL, + "wet": HVAC_MODE_DRY, + "wind": HVAC_MODE_FAN_ONLY, + "auto": HVAC_MODE_AUTO, } -TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} - -FAN_MODES = {FAN_LOW, FAN_MEDIUM, FAN_HIGH} +TUYA_SUPPORT_TYPE = { + "kt", # Air conditioner + "qn", # Heater + "wk", # Thermostat +} -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya climate dynamically through tuya discovery.""" + _LOGGER.debug("climate init") - platform = config_entry.data[CONF_PLATFORM] + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" + @callback + def async_discover_device(dev_ids: list[str]) -> None: + """Discover and add a discovered tuya climate.""" + _LOGGER.debug("climate add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Climate device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) +def _setup_entities( + hass: HomeAssistant, entry: ConfigEntry, device_ids: list[str] +) -> list[Entity]: + """Set up Tuya Climate.""" + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + entities: list[Entity] = [] + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaClimateEntity(device, platform)) + entities.append(TuyaHaClimate(device, device_manager)) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add(device_id) return entities -class TuyaClimateEntity(TuyaDevice, ClimateEntity): - """Tuya climate devices,include air conditioner,heater.""" +class TuyaHaClimate(TuyaHaEntity, ClimateEntity): + """Tuya Switch Device.""" - def __init__(self, tuya, platform): - """Init climate device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self.operations = [HVAC_MODE_OFF] - self._has_operation = False - self._def_hvac_mode = HVAC_MODE_AUTO - self._set_temp_divided = True - self._temp_step_override = None - self._min_temp = None - self._max_temp = None - - @callback - def _process_config(self): - """Set device config parameter.""" - config = self._get_device_config() - if not config: - return - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - if unit: - self._tuya.set_unit("FAHRENHEIT" if unit == TEMP_FAHRENHEIT else "CELSIUS") - self._tuya.temp_divider = config.get(CONF_TEMP_DIVIDER, 0) - self._tuya.curr_temp_divider = config.get(CONF_CURR_TEMP_DIVIDER, 0) - self._set_temp_divided = config.get(CONF_SET_TEMP_DIVIDED, True) - self._temp_step_override = config.get(CONF_TEMP_STEP_OVERRIDE) - min_temp = config.get(CONF_MIN_TEMP, 0) - max_temp = config.get(CONF_MAX_TEMP, 0) - if min_temp >= max_temp: - self._min_temp = self._max_temp = None + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init Tuya Ha Climate.""" + super().__init__(device, device_manager) + if DPCODE_C_F in self.tuya_device.status: + self.dp_temp_unit = DPCODE_C_F else: - self._min_temp = min_temp - self._max_temp = max_temp + self.dp_temp_unit = DPCODE_TEMP_UNIT_CONVERT - async def async_added_to_hass(self): - """Create operation list when add to hass.""" - await super().async_added_to_hass() - self._process_config() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_CONFIG_ENTITY, self._process_config - ) - ) - - modes = self._tuya.operation_list() - if modes is None: - if self._def_hvac_mode not in self.operations: - self.operations.append(self._def_hvac_mode) - return - - for mode in modes: - if mode not in TUYA_STATE_TO_HA: - continue - ha_mode = TUYA_STATE_TO_HA[mode] - if ha_mode not in self.operations: - self.operations.append(ha_mode) - self._has_operation = True - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - unit = self._tuya.temperature_unit() - if unit == "FAHRENHEIT": - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - @property - def hvac_mode(self): - """Return current operation ie. heat, cool, idle.""" - if not self._tuya.state(): - return HVAC_MODE_OFF - - if not self._has_operation: - return self._def_hvac_mode - - mode = self._tuya.current_operation() - if mode is None: + def get_temp_set_scale(self) -> int | None: + """Get temperature set scale.""" + dp_temp_set = DPCODE_TEMP_SET if self.is_celsius() else DPCODE_TEMP_SET_F + temp_set_value_range_item = self.tuya_device.status_range.get(dp_temp_set) + if not temp_set_value_range_item: return None - return TUYA_STATE_TO_HA.get(mode) - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return self.operations + temp_set_value_range = json.loads(temp_set_value_range_item.values) + return temp_set_value_range.get("scale") - @property - def current_temperature(self): - """Return the current temperature.""" - return self._tuya.current_temperature() + def get_temp_current_scale(self) -> int | None: + """Get temperature current scale.""" + dp_temp_current = ( + DPCODE_TEMP_CURRENT if self.is_celsius() else DPCODE_TEMP_CURRENT_F + ) + temp_current_value_range_item = self.tuya_device.status_range.get( + dp_temp_current + ) + if not temp_current_value_range_item: + return None - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._tuya.target_temperature() + temp_current_value_range = json.loads(temp_current_value_range_item.values) + return temp_current_value_range.get("scale") - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - if self._temp_step_override: - return self._temp_step_override - return self._tuya.target_temperature_step() + # Functions - @property - def fan_mode(self): - """Return the fan setting.""" - return self._tuya.current_fan_mode() - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return self._tuya.fan_list() - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - self._tuya.set_temperature(kwargs[ATTR_TEMPERATURE], self._set_temp_divided) - - def set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - self._tuya.set_fan_mode(fan_mode) - - def set_hvac_mode(self, hvac_mode): - """Set new target operation mode.""" + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + commands = [] if hvac_mode == HVAC_MODE_OFF: - self._tuya.turn_off() + commands.append({"code": DPCODE_SWITCH, "value": False}) + else: + commands.append({"code": DPCODE_SWITCH, "value": True}) + + for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items(): + if ha_mode == hvac_mode: + commands.append({"code": DPCODE_MODE, "value": tuya_mode}) + break + + self._send_command(commands) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + self._send_command([{"code": DPCODE_FAN_SPEED_ENUM, "value": fan_mode}]) + + def set_humidity(self, humidity: float) -> None: + """Set new target humidity.""" + self._send_command([{"code": DPCODE_HUMIDITY_SET, "value": int(humidity)}]) + + def set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + if swing_mode == SWING_BOTH: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": True}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": True}, + ] + elif swing_mode == SWING_HORIZONTAL: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": False}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": True}, + ] + elif swing_mode == SWING_VERTICAL: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": True}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": False}, + ] + else: + commands = [ + {"code": DPCODE_SWITCH_VERTICAL, "value": False}, + {"code": DPCODE_SWITCH_HORIZONTAL, "value": False}, + ] + + self._send_command(commands) + + def set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + _LOGGER.debug("climate temp-> %s", kwargs) + code = DPCODE_TEMP_SET if self.is_celsius() else DPCODE_TEMP_SET_F + temp_set_scale = self.get_temp_set_scale() + if not temp_set_scale: return - if not self._tuya.state(): - self._tuya.turn_on() - - if self._has_operation: - self._tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode)) - - @property - def supported_features(self): - """Return the list of supported features.""" - supports = 0 - if self._tuya.support_target_temperature(): - supports = supports | SUPPORT_TARGET_TEMPERATURE - if self._tuya.support_wind_speed(): - supports = supports | SUPPORT_FAN_MODE - return supports - - @property - def min_temp(self): - """Return the minimum temperature.""" - min_temp = ( - self._min_temp if self._min_temp is not None else self._tuya.min_temp() + self._send_command( + [ + { + "code": code, + "value": int(kwargs["temperature"] * (10 ** temp_set_scale)), + } + ] ) - if min_temp is not None: - return min_temp - return super().min_temp + + def is_celsius(self) -> bool: + """Return True if device reports in Celsius.""" + if ( + self.dp_temp_unit in self.tuya_device.status + and self.tuya_device.status.get(self.dp_temp_unit).lower() == "c" + ): + return True + if ( + DPCODE_TEMP_SET in self.tuya_device.status + or DPCODE_TEMP_CURRENT in self.tuya_device.status + ): + return True + return False @property - def max_temp(self): + def temperature_unit(self) -> str: + """Return true if fan is on.""" + if self.is_celsius(): + return TEMP_CELSIUS + return TEMP_FAHRENHEIT + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if ( + DPCODE_TEMP_CURRENT not in self.tuya_device.status + and DPCODE_TEMP_CURRENT_F not in self.tuya_device.status + ): + return None + + temp_current_scale = self.get_temp_current_scale() + if not temp_current_scale: + return None + + if self.is_celsius(): + temperature = self.tuya_device.status.get(DPCODE_TEMP_CURRENT) + if not temperature: + return None + return temperature * 1.0 / (10 ** temp_current_scale) + + temperature = self.tuya_device.status.get(DPCODE_TEMP_CURRENT_F) + if not temperature: + return None + return temperature * 1.0 / (10 ** temp_current_scale) + + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return int(self.tuya_device.status.get(DPCODE_HUMIDITY_CURRENT, 0)) + + @property + def target_temperature(self) -> float | None: + """Return the temperature currently set to be reached.""" + temp_set_scale = self.get_temp_set_scale() + if temp_set_scale is None: + return None + + dpcode_temp_set = self.tuya_device.status.get(DPCODE_TEMP_SET) + if dpcode_temp_set is None: + return None + + return dpcode_temp_set * 1.0 / (10 ** temp_set_scale) + + @property + def max_temp(self) -> float: """Return the maximum temperature.""" - max_temp = ( - self._max_temp if self._max_temp is not None else self._tuya.max_temp() + scale = self.get_temp_set_scale() + if scale is None: + return DEFAULT_MAX_TEMP + + if self.is_celsius(): + if DPCODE_TEMP_SET not in self.tuya_device.function: + return DEFAULT_MAX_TEMP + + function_item = self.tuya_device.function.get(DPCODE_TEMP_SET) + if function_item is None: + return DEFAULT_MAX_TEMP + + temp_value = json.loads(function_item.values) + + temp_max = temp_value.get("max") + if temp_max is None: + return DEFAULT_MAX_TEMP + return temp_max * 1.0 / (10 ** scale) + if DPCODE_TEMP_SET_F not in self.tuya_device.function: + return DEFAULT_MAX_TEMP + + function_item_f = self.tuya_device.function.get(DPCODE_TEMP_SET_F) + if function_item_f is None: + return DEFAULT_MAX_TEMP + + temp_value_f = json.loads(function_item_f.values) + + temp_max_f = temp_value_f.get("max") + if temp_max_f is None: + return DEFAULT_MAX_TEMP + return temp_max_f * 1.0 / (10 ** scale) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + temp_set_scal = self.get_temp_set_scale() + if temp_set_scal is None: + return DEFAULT_MIN_TEMP + + if self.is_celsius(): + if DPCODE_TEMP_SET not in self.tuya_device.function: + return DEFAULT_MIN_TEMP + + function_temp_item = self.tuya_device.function.get(DPCODE_TEMP_SET) + if function_temp_item is None: + return DEFAULT_MIN_TEMP + temp_value = json.loads(function_temp_item.values) + temp_min = temp_value.get("min") + if temp_min is None: + return DEFAULT_MIN_TEMP + return temp_min * 1.0 / (10 ** temp_set_scal) + + if DPCODE_TEMP_SET_F not in self.tuya_device.function: + return DEFAULT_MIN_TEMP + + temp_value_temp_f = self.tuya_device.function.get(DPCODE_TEMP_SET_F) + if temp_value_temp_f is None: + return DEFAULT_MIN_TEMP + temp_value_f = json.loads(temp_value_temp_f.values) + + temp_min_f = temp_value_f.get("min") + if temp_min_f is None: + return DEFAULT_MIN_TEMP + + return temp_min_f * 1.0 / (10 ** temp_set_scal) + + @property + def target_temperature_step(self) -> float | None: + """Return target temperature setp.""" + if ( + DPCODE_TEMP_SET not in self.tuya_device.status_range + and DPCODE_TEMP_SET_F not in self.tuya_device.status_range + ): + return 1.0 + temp_set_value_range = json.loads( + self.tuya_device.status_range.get( + DPCODE_TEMP_SET if self.is_celsius() else DPCODE_TEMP_SET_F + ).values ) - if max_temp is not None: - return max_temp - return super().max_temp + step = temp_set_value_range.get("step") + if step is None: + return None + + temp_set_scale = self.get_temp_set_scale() + if temp_set_scale is None: + return None + + return step * 1.0 / (10 ** temp_set_scale) + + @property + def target_humidity(self) -> int: + """Return target humidity.""" + return int(self.tuya_device.status.get(DPCODE_HUMIDITY_SET, 0)) + + @property + def hvac_mode(self) -> str: + """Return hvac mode.""" + if not self.tuya_device.status.get(DPCODE_SWITCH, False): + return HVAC_MODE_OFF + if DPCODE_MODE not in self.tuya_device.status: + return HVAC_MODE_OFF + if self.tuya_device.status.get(DPCODE_MODE) is not None: + return TUYA_HVAC_TO_HA[self.tuya_device.status[DPCODE_MODE]] + return HVAC_MODE_OFF + + @property + def hvac_modes(self) -> list[str]: + """Return hvac modes for select.""" + if DPCODE_MODE not in self.tuya_device.function: + return [] + modes = json.loads(self.tuya_device.function.get(DPCODE_MODE, {}).values).get( + "range" + ) + + hvac_modes = [HVAC_MODE_OFF] + for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items(): + if tuya_mode in modes: + hvac_modes.append(ha_mode) + + return hvac_modes + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + return self.tuya_device.status.get(DPCODE_FAN_SPEED_ENUM) + + @property + def fan_modes(self) -> list[str]: + """Return fan modes for select.""" + fan_speed_device_function = self.tuya_device.function.get(DPCODE_FAN_SPEED_ENUM) + if not fan_speed_device_function: + return [] + return json.loads(fan_speed_device_function.values).get("range", []) + + @property + def swing_mode(self) -> str: + """Return swing mode.""" + mode = 0 + if ( + DPCODE_SWITCH_HORIZONTAL in self.tuya_device.status + and self.tuya_device.status.get(DPCODE_SWITCH_HORIZONTAL) + ): + mode += 1 + if ( + DPCODE_SWITCH_VERTICAL in self.tuya_device.status + and self.tuya_device.status.get(DPCODE_SWITCH_VERTICAL) + ): + mode += 2 + + if mode == 3: + return SWING_BOTH + if mode == 2: + return SWING_VERTICAL + if mode == 1: + return SWING_HORIZONTAL + return SWING_OFF + + @property + def swing_modes(self) -> list[str]: + """Return swing mode for select.""" + return [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH] + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supports = 0 + if ( + DPCODE_TEMP_SET in self.tuya_device.status + or DPCODE_TEMP_SET_F in self.tuya_device.status + ): + supports |= SUPPORT_TARGET_TEMPERATURE + if DPCODE_FAN_SPEED_ENUM in self.tuya_device.status: + supports |= SUPPORT_FAN_MODE + if DPCODE_HUMIDITY_SET in self.tuya_device.status: + supports |= SUPPORT_TARGET_HUMIDITY + if ( + DPCODE_SWITCH_HORIZONTAL in self.tuya_device.status + or DPCODE_SWITCH_VERTICAL in self.tuya_device.status + ): + supports |= SUPPORT_SWING_MODE + return supports diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 476a2295fc4..bcde364ae1b 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -4,406 +4,144 @@ from __future__ import annotations import logging from typing import Any -from tuyaha import TuyaApi -from tuyaha.tuyaapi import ( - TuyaAPIException, - TuyaAPIRateLimitException, - TuyaNetException, - TuyaServerException, -) +from tuya_iot import AuthType, TuyaOpenAPI import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_PASSWORD, - CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, - ENTITY_MATCH_NONE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv from .const import ( - CONF_BRIGHTNESS_RANGE_MODE, - CONF_COUNTRYCODE, - CONF_CURR_TEMP_DIVIDER, - CONF_DISCOVERY_INTERVAL, - CONF_MAX_KELVIN, - CONF_MAX_TEMP, - CONF_MIN_KELVIN, - CONF_MIN_TEMP, - CONF_QUERY_DEVICE, - CONF_QUERY_INTERVAL, - CONF_SET_TEMP_DIVIDED, - CONF_SUPPORT_COLOR, - CONF_TEMP_DIVIDER, - CONF_TEMP_STEP_OVERRIDE, - CONF_TUYA_MAX_COLTEMP, - DEFAULT_DISCOVERY_INTERVAL, - DEFAULT_QUERY_INTERVAL, - DEFAULT_TUYA_MAX_COLTEMP, + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, + CONF_PASSWORD, + CONF_USERNAME, DOMAIN, - TUYA_DATA, - TUYA_PLATFORMS, - TUYA_TYPE_NOT_QUERY, + SMARTLIFE_APP, + TUYA_COUNTRIES, + TUYA_RESPONSE_CODE, + TUYA_RESPONSE_MSG, + TUYA_RESPONSE_PLATFROM_URL, + TUYA_RESPONSE_RESULT, + TUYA_RESPONSE_SUCCESS, + TUYA_SMART_APP, ) _LOGGER = logging.getLogger(__name__) -CONF_LIST_DEVICES = "list_devices" - -DATA_SCHEMA_USER = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_COUNTRYCODE): vol.Coerce(int), - vol.Required(CONF_PLATFORM): vol.In(TUYA_PLATFORMS), - } -) - -ERROR_DEV_MULTI_TYPE = "dev_multi_type" -ERROR_DEV_NOT_CONFIG = "dev_not_config" -ERROR_DEV_NOT_FOUND = "dev_not_found" - -RESULT_AUTH_FAILED = "invalid_auth" -RESULT_CONN_ERROR = "cannot_connect" -RESULT_SINGLE_INSTANCE = "single_instance_allowed" -RESULT_SUCCESS = "success" - -RESULT_LOG_MESSAGE = { - RESULT_AUTH_FAILED: "Invalid credential", - RESULT_CONN_ERROR: "Connection error", -} - -TUYA_TYPE_CONFIG = ["climate", "light"] - class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a tuya config flow.""" + """Tuya Config Flow.""" - VERSION = 1 + @staticmethod + def _try_login(user_input: dict[str, Any]) -> tuple[dict[Any, Any], dict[str, Any]]: + """Try login.""" + response = {} - def __init__(self) -> None: - """Initialize flow.""" - self._country_code = None - self._password = None - self._platform = None - self._username = None + country = [ + country + for country in TUYA_COUNTRIES + if country.name == user_input[CONF_COUNTRY_CODE] + ][0] - def _save_entry(self): - return self.async_create_entry( - title=self._username, - data={ - CONF_COUNTRYCODE: self._country_code, - CONF_PASSWORD: self._password, - CONF_PLATFORM: self._platform, - CONF_USERNAME: self._username, - }, - ) + data = { + CONF_ENDPOINT: country.endpoint, + CONF_AUTH_TYPE: AuthType.CUSTOM, + CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], + CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_COUNTRY_CODE: country.country_code, + } - def _try_connect(self): - """Try to connect and check auth.""" - tuya = TuyaApi() - try: - tuya.init( - self._username, self._password, self._country_code, self._platform + for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): + data[CONF_APP_TYPE] = app_type + if data[CONF_APP_TYPE] == "": + data[CONF_AUTH_TYPE] = AuthType.CUSTOM + else: + data[CONF_AUTH_TYPE] = AuthType.SMART_HOME + + api = TuyaOpenAPI( + endpoint=data[CONF_ENDPOINT], + access_id=data[CONF_ACCESS_ID], + access_secret=data[CONF_ACCESS_SECRET], + auth_type=data[CONF_AUTH_TYPE], ) - except (TuyaAPIRateLimitException, TuyaNetException, TuyaServerException): - return RESULT_CONN_ERROR - except TuyaAPIException: - return RESULT_AUTH_FAILED + api.set_dev_channel("hass") - return RESULT_SUCCESS + response = api.connect( + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + country_code=data[CONF_COUNTRY_CODE], + schema=data[CONF_APP_TYPE], + ) + + _LOGGER.debug("Response %s", response) + + if response.get(TUYA_RESPONSE_SUCCESS, False): + break + + return response, data async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - if self._async_current_entries(): - return self.async_abort(reason=RESULT_SINGLE_INSTANCE) - + """Step user.""" errors = {} + placeholders = {} if user_input is not None: - - self._country_code = str(user_input[CONF_COUNTRYCODE]) - self._password = user_input[CONF_PASSWORD] - self._platform = user_input[CONF_PLATFORM] - self._username = user_input[CONF_USERNAME] - - result = await self.hass.async_add_executor_job(self._try_connect) - - if result == RESULT_SUCCESS: - return self._save_entry() - if result != RESULT_AUTH_FAILED: - return self.async_abort(reason=result) - errors["base"] = result - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors - ) - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for Tuya.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self._conf_devs_id = None - self._conf_devs_option: dict[str, Any] = {} - self._form_error = None - - def _get_form_error(self): - """Set the error to be shown in the options form.""" - errors = {} - if self._form_error: - errors["base"] = self._form_error - self._form_error = None - return errors - - def _get_tuya_devices_filtered(self, types, exclude_mode=False, type_prefix=True): - """Get the list of Tuya device to filtered by types.""" - config_list = {} - types_filter = set(types) - tuya = self.hass.data[DOMAIN][TUYA_DATA] - devices_list = tuya.get_all_devices() - for device in devices_list: - dev_type = device.device_type() - exclude = ( - dev_type in types_filter - if exclude_mode - else dev_type not in types_filter + response, data = await self.hass.async_add_executor_job( + self._try_login, user_input ) - if exclude: - continue - dev_id = device.object_id() - if type_prefix: - dev_id = f"{dev_type}-{dev_id}" - config_list[dev_id] = f"{device.name()} ({dev_type})" - return config_list + if response.get(TUYA_RESPONSE_SUCCESS, False): + if endpoint := response.get(TUYA_RESPONSE_RESULT, {}).get( + TUYA_RESPONSE_PLATFROM_URL + ): + data[CONF_ENDPOINT] = endpoint - def _get_device(self, dev_id): - """Get specific device from tuya library.""" - tuya = self.hass.data[DOMAIN][TUYA_DATA] - return tuya.get_device_by_id(dev_id) + data[CONF_AUTH_TYPE] = data[CONF_AUTH_TYPE].value - def _save_config(self, data): - """Save the updated options.""" - curr_conf = self.config_entry.options.copy() - curr_conf.update(data) - curr_conf.update(self._conf_devs_option) + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=data, + ) + errors["base"] = "login_error" + placeholders = { + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE), + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), + } - return self.async_create_entry(title="", data=curr_conf) - - async def _async_device_form(self, devs_id): - """Return configuration form for devices.""" - conf_devs_id = [] - for count, dev_id in enumerate(devs_id): - device_info = dev_id.split("-") - if count == 0: - device_type = device_info[0] - device_id = device_info[1] - elif device_type != device_info[0]: - self._form_error = ERROR_DEV_MULTI_TYPE - return await self.async_step_init() - conf_devs_id.append(device_info[1]) - - device = self._get_device(device_id) - if not device: - self._form_error = ERROR_DEV_NOT_FOUND - return await self.async_step_init() - - curr_conf = self._conf_devs_option.get( - device_id, self.config_entry.options.get(device_id, {}) - ) - - config_schema = self._get_device_schema(device_type, curr_conf, device) - if not config_schema: - self._form_error = ERROR_DEV_NOT_CONFIG - return await self.async_step_init() - - self._conf_devs_id = conf_devs_id - device_name = ( - "(multiple devices selected)" if len(conf_devs_id) > 1 else device.name() - ) + if user_input is None: + user_input = {} return self.async_show_form( - step_id="device", - data_schema=config_schema, - description_placeholders={ - "device_type": device_type, - "device_name": device_name, - }, - ) - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - - if self.config_entry.state is not config_entries.ConfigEntryState.LOADED: - _LOGGER.error("Tuya integration not yet loaded") - return self.async_abort(reason=RESULT_CONN_ERROR) - - if user_input is not None: - dev_ids = user_input.get(CONF_LIST_DEVICES) - if dev_ids: - return await self.async_step_device(None, dev_ids) - - user_input.pop(CONF_LIST_DEVICES, []) - return self._save_config(data=user_input) - - data_schema = vol.Schema( - { - vol.Optional( - CONF_DISCOVERY_INTERVAL, - default=self.config_entry.options.get( - CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL - ), - ): vol.All(vol.Coerce(int), vol.Clamp(min=30, max=900)), - } - ) - - query_devices = self._get_tuya_devices_filtered( - TUYA_TYPE_NOT_QUERY, True, False - ) - if query_devices: - devices = {ENTITY_MATCH_NONE: "Default"} - devices.update(query_devices) - def_val = self.config_entry.options.get(CONF_QUERY_DEVICE) - if not def_val or not query_devices.get(def_val): - def_val = ENTITY_MATCH_NONE - data_schema = data_schema.extend( + step_id="user", + data_schema=vol.Schema( { - vol.Optional( - CONF_QUERY_INTERVAL, - default=self.config_entry.options.get( - CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL - ), - ): vol.All(vol.Coerce(int), vol.Clamp(min=30, max=240)), - vol.Optional(CONF_QUERY_DEVICE, default=def_val): vol.In(devices), + vol.Required( + CONF_COUNTRY_CODE, + default=user_input.get(CONF_COUNTRY_CODE, "United States"), + ): vol.In( + # We don't pass a dict {code:name} because country codes can be duplicate. + [country.name for country in TUYA_COUNTRIES] + ), + vol.Required( + CONF_ACCESS_ID, default=user_input.get(CONF_ACCESS_ID, "") + ): str, + vol.Required( + CONF_ACCESS_SECRET, + default=user_input.get(CONF_ACCESS_SECRET, ""), + ): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } - ) - - config_devices = self._get_tuya_devices_filtered(TUYA_TYPE_CONFIG, False, True) - if config_devices: - data_schema = data_schema.extend( - {vol.Optional(CONF_LIST_DEVICES): cv.multi_select(config_devices)} - ) - - return self.async_show_form( - step_id="init", - data_schema=data_schema, - errors=self._get_form_error(), + ), + errors=errors, + description_placeholders=placeholders, ) - - async def async_step_device(self, user_input=None, dev_ids=None): - """Handle options flow for device.""" - if dev_ids is not None: - return await self._async_device_form(dev_ids) - if user_input is not None: - for device_id in self._conf_devs_id: - self._conf_devs_option[device_id] = user_input - - return await self.async_step_init() - - def _get_device_schema(self, device_type, curr_conf, device): - """Return option schema for device.""" - if device_type != device.device_type(): - return None - schema = None - if device_type == "light": - schema = self._get_light_schema(curr_conf, device) - elif device_type == "climate": - schema = self._get_climate_schema(curr_conf, device) - return schema - - @staticmethod - def _get_light_schema(curr_conf, device): - """Create option schema for light device.""" - min_kelvin = device.max_color_temp() - max_kelvin = device.min_color_temp() - - config_schema = vol.Schema( - { - vol.Optional( - CONF_SUPPORT_COLOR, - default=curr_conf.get(CONF_SUPPORT_COLOR, False), - ): bool, - vol.Optional( - CONF_BRIGHTNESS_RANGE_MODE, - default=curr_conf.get(CONF_BRIGHTNESS_RANGE_MODE, 0), - ): vol.In({0: "Range 1-255", 1: "Range 10-1000"}), - vol.Optional( - CONF_MIN_KELVIN, - default=curr_conf.get(CONF_MIN_KELVIN, min_kelvin), - ): vol.All(vol.Coerce(int), vol.Clamp(min=min_kelvin, max=max_kelvin)), - vol.Optional( - CONF_MAX_KELVIN, - default=curr_conf.get(CONF_MAX_KELVIN, max_kelvin), - ): vol.All(vol.Coerce(int), vol.Clamp(min=min_kelvin, max=max_kelvin)), - vol.Optional( - CONF_TUYA_MAX_COLTEMP, - default=curr_conf.get( - CONF_TUYA_MAX_COLTEMP, DEFAULT_TUYA_MAX_COLTEMP - ), - ): vol.All( - vol.Coerce(int), - vol.Clamp( - min=DEFAULT_TUYA_MAX_COLTEMP, max=DEFAULT_TUYA_MAX_COLTEMP * 10 - ), - ), - } - ) - - return config_schema - - @staticmethod - def _get_climate_schema(curr_conf, device): - """Create option schema for climate device.""" - unit = device.temperature_unit() - def_unit = TEMP_FAHRENHEIT if unit == "FAHRENHEIT" else TEMP_CELSIUS - supported_steps = device.supported_temperature_steps() - default_step = device.target_temperature_step() - - config_schema = vol.Schema( - { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - default=curr_conf.get(CONF_UNIT_OF_MEASUREMENT, def_unit), - ): vol.In({TEMP_CELSIUS: "Celsius", TEMP_FAHRENHEIT: "Fahrenheit"}), - vol.Optional( - CONF_TEMP_DIVIDER, - default=curr_conf.get(CONF_TEMP_DIVIDER, 0), - ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), - vol.Optional( - CONF_CURR_TEMP_DIVIDER, - default=curr_conf.get(CONF_CURR_TEMP_DIVIDER, 0), - ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), - vol.Optional( - CONF_SET_TEMP_DIVIDED, - default=curr_conf.get(CONF_SET_TEMP_DIVIDED, True), - ): bool, - vol.Optional( - CONF_TEMP_STEP_OVERRIDE, - default=curr_conf.get(CONF_TEMP_STEP_OVERRIDE, default_step), - ): vol.In(supported_steps), - vol.Optional( - CONF_MIN_TEMP, - default=curr_conf.get(CONF_MIN_TEMP, 0), - ): int, - vol.Optional( - CONF_MAX_TEMP, - default=curr_conf.get(CONF_MAX_TEMP, 0), - ): int, - } - ) - - return config_schema diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 646bcc077cf..44b66b576e3 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,39 +1,295 @@ """Constants for the Tuya integration.""" - -CONF_BRIGHTNESS_RANGE_MODE = "brightness_range_mode" -CONF_COUNTRYCODE = "country_code" -CONF_CURR_TEMP_DIVIDER = "curr_temp_divider" -CONF_DISCOVERY_INTERVAL = "discovery_interval" -CONF_MAX_KELVIN = "max_kelvin" -CONF_MAX_TEMP = "max_temp" -CONF_MIN_KELVIN = "min_kelvin" -CONF_MIN_TEMP = "min_temp" -CONF_QUERY_DEVICE = "query_device" -CONF_QUERY_INTERVAL = "query_interval" -CONF_SET_TEMP_DIVIDED = "set_temp_divided" -CONF_SUPPORT_COLOR = "support_color" -CONF_TEMP_DIVIDER = "temp_divider" -CONF_TEMP_STEP_OVERRIDE = "temp_step_override" -CONF_TUYA_MAX_COLTEMP = "tuya_max_coltemp" - -DEFAULT_DISCOVERY_INTERVAL = 605 -DEFAULT_QUERY_INTERVAL = 120 -DEFAULT_TUYA_MAX_COLTEMP = 10000 +from dataclasses import dataclass DOMAIN = "tuya" -SIGNAL_CONFIG_ENTITY = "tuya_config" -SIGNAL_DELETE_ENTITY = "tuya_delete" -SIGNAL_UPDATE_ENTITY = "tuya_update" +CONF_AUTH_TYPE = "auth_type" +CONF_PROJECT_TYPE = "tuya_project_type" +CONF_ENDPOINT = "endpoint" +CONF_ACCESS_ID = "access_id" +CONF_ACCESS_SECRET = "access_secret" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_COUNTRY_CODE = "country_code" +CONF_APP_TYPE = "tuya_app_type" -TUYA_DATA = "tuya_data" -TUYA_DEVICES_CONF = "devices_config" TUYA_DISCOVERY_NEW = "tuya_discovery_new_{}" +TUYA_DEVICE_MANAGER = "tuya_device_manager" +TUYA_HOME_MANAGER = "tuya_home_manager" +TUYA_MQTT_LISTENER = "tuya_mqtt_listener" +TUYA_HA_TUYA_MAP = "tuya_ha_tuya_map" +TUYA_HA_DEVICES = "tuya_ha_devices" -TUYA_PLATFORMS = { - "tuya": "Tuya", - "smart_life": "Smart Life", - "jinvoo_smart": "Jinvoo Smart", -} +TUYA_RESPONSE_CODE = "code" +TUYA_RESPONSE_RESULT = "result" +TUYA_RESPONSE_MSG = "msg" +TUYA_RESPONSE_SUCCESS = "success" +TUYA_RESPONSE_PLATFROM_URL = "platform_url" -TUYA_TYPE_NOT_QUERY = ["scene", "switch"] +TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" + +TUYA_SMART_APP = "tuyaSmart" +SMARTLIFE_APP = "smartlife" + +ENDPOINT_AMERICA = "https://openapi.tuyaus.com" +ENDPOINT_CHINA = "https://openapi.tuyacn.com" +ENDPOINT_EASTERN_AMERICA = "https://openapi-ueaz.tuyaus.com" +ENDPOINT_EUROPE = "https://openapi.tuyaeu.com" +ENDPOINT_INDIA = "https://openapi.tuyain.com" +ENDPOINT_WESTERN_EUROPE = "https://openapi-weaz.tuyaeu.com" + +PLATFORMS = ["climate", "fan", "light", "scene", "switch"] + + +@dataclass +class Country: + """Describe a supported country.""" + + name: str + country_code: str + endpoint: str = ENDPOINT_AMERICA + + +# https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb#title-4-China%20Data%20Center +TUYA_COUNTRIES = [ + Country("Afghanistan", "93"), + Country("Albania", "355"), + Country("Algeria", "213"), + Country("American Samoa", "1-684"), + Country("Andorra", "376"), + Country("Angola", "244"), + Country("Anguilla", "1-264"), + Country("Antarctica", "672"), + Country("Antigua and Barbuda", "1-268"), + Country("Argentina", "54", ENDPOINT_EUROPE), + Country("Armenia", "374"), + Country("Aruba", "297"), + Country("Australia", "61"), + Country("Austria", "43", ENDPOINT_EUROPE), + Country("Azerbaijan", "994"), + Country("Bahamas", "1-242"), + Country("Bahrain", "973"), + Country("Bangladesh", "880"), + Country("Barbados", "1-246"), + Country("Belarus", "375"), + Country("Belgium", "32", ENDPOINT_EUROPE), + Country("Belize", "501"), + Country("Benin", "229"), + Country("Bermuda", "1-441"), + Country("Bhutan", "975"), + Country("Bolivia", "591"), + Country("Bosnia and Herzegovina", "387"), + Country("Botswana", "267"), + Country("Brazil", "55", ENDPOINT_EUROPE), + Country("British Indian Ocean Territory", "246"), + Country("British Virgin Islands", "1-284"), + Country("Brunei", "673"), + Country("Bulgaria", "359"), + Country("Burkina Faso", "226"), + Country("Burundi", "257"), + Country("Cambodia", "855"), + Country("Cameroon", "237"), + Country("Canada", "1", ENDPOINT_AMERICA), + Country("Cape Verde", "238"), + Country("Cayman Islands", "1-345"), + Country("Central African Republic", "236"), + Country("Chad", "235"), + Country("Chile", "56"), + Country("China", "86", ENDPOINT_CHINA), + Country("Christmas Island", "61"), + Country("Cocos Islands", "61"), + Country("Colombia", "57"), + Country("Comoros", "269"), + Country("Cook Islands", "682"), + Country("Costa Rica", "506"), + Country("Croatia", "385", ENDPOINT_EUROPE), + Country("Cuba", "53"), + Country("Curacao", "599"), + Country("Cyprus", "357", ENDPOINT_EUROPE), + Country("Czech Republic", "420", ENDPOINT_EUROPE), + Country("Democratic Republic of the Congo", "243"), + Country("Denmark", "45", ENDPOINT_EUROPE), + Country("Djibouti", "253"), + Country("Dominica", "1-767"), + Country("Dominican Republic", "1-809"), + Country("East Timor", "670"), + Country("Ecuador", "593"), + Country("Egypt", "20"), + Country("El Salvador", "503"), + Country("Equatorial Guinea", "240"), + Country("Eritrea", "291"), + Country("Estonia", "372", ENDPOINT_EUROPE), + Country("Ethiopia", "251"), + Country("Falkland Islands", "500"), + Country("Faroe Islands", "298"), + Country("Fiji", "679"), + Country("Finland", "358", ENDPOINT_EUROPE), + Country("France", "33", ENDPOINT_EUROPE), + Country("French Polynesia", "689"), + Country("Gabon", "241"), + Country("Gambia", "220"), + Country("Georgia", "995"), + Country("Germany", "49", ENDPOINT_EUROPE), + Country("Ghana", "233"), + Country("Gibraltar", "350"), + Country("Greece", "30", ENDPOINT_EUROPE), + Country("Greenland", "299"), + Country("Grenada", "1-473"), + Country("Guam", "1-671"), + Country("Guatemala", "502"), + Country("Guernsey", "44-1481"), + Country("Guinea", "224"), + Country("Guinea-Bissau", "245"), + Country("Guyana", "592"), + Country("Haiti", "509"), + Country("Honduras", "504"), + Country("Hong Kong", "852"), + Country("Hungary", "36", ENDPOINT_EUROPE), + Country("Iceland", "354", ENDPOINT_EUROPE), + Country("India", "91", ENDPOINT_INDIA), + Country("Indonesia", "62"), + Country("Iran", "98"), + Country("Iraq", "964"), + Country("Ireland", "353", ENDPOINT_EUROPE), + Country("Isle of Man", "44-1624"), + Country("Israel", "972"), + Country("Italy", "39", ENDPOINT_EUROPE), + Country("Ivory Coast", "225"), + Country("Jamaica", "1-876"), + Country("Japan", "81", ENDPOINT_EUROPE), + Country("Jersey", "44-1534"), + Country("Jordan", "962"), + Country("Kazakhstan", "7"), + Country("Kenya", "254"), + Country("Kiribati", "686"), + Country("Kosovo", "383"), + Country("Kuwait", "965"), + Country("Kyrgyzstan", "996"), + Country("Laos", "856"), + Country("Latvia", "371", ENDPOINT_EUROPE), + Country("Lebanon", "961"), + Country("Lesotho", "266"), + Country("Liberia", "231"), + Country("Libya", "218"), + Country("Liechtenstein", "423", ENDPOINT_EUROPE), + Country("Lithuania", "370", ENDPOINT_EUROPE), + Country("Luxembourg", "352", ENDPOINT_EUROPE), + Country("Macau", "853"), + Country("Macedonia", "389"), + Country("Madagascar", "261"), + Country("Malawi", "265"), + Country("Malaysia", "60"), + Country("Maldives", "960"), + Country("Mali", "223"), + Country("Malta", "356", ENDPOINT_EUROPE), + Country("Marshall Islands", "692"), + Country("Mauritania", "222"), + Country("Mauritius", "230"), + Country("Mayotte", "262"), + Country("Mexico", "52"), + Country("Micronesia", "691"), + Country("Moldova", "373"), + Country("Monaco", "377"), + Country("Mongolia", "976"), + Country("Montenegro", "382"), + Country("Montserrat", "1-664"), + Country("Morocco", "212"), + Country("Mozambique", "258"), + Country("Myanmar", "95"), + Country("Namibia", "264"), + Country("Nauru", "674"), + Country("Nepal", "977"), + Country("Netherlands", "31", ENDPOINT_EUROPE), + Country("Netherlands Antilles", "599"), + Country("New Caledonia", "687"), + Country("New Zealand", "64"), + Country("Nicaragua", "505"), + Country("Niger", "227"), + Country("Nigeria", "234"), + Country("Niue", "683"), + Country("North Korea", "850"), + Country("Northern Mariana Islands", "1-670"), + Country("Norway", "47"), + Country("Oman", "968"), + Country("Pakistan", "92"), + Country("Palau", "680"), + Country("Palestine", "970"), + Country("Panama", "507"), + Country("Papua New Guinea", "675"), + Country("Paraguay", "595"), + Country("Peru", "51"), + Country("Philippines", "63"), + Country("Pitcairn", "64"), + Country("Poland", "48", ENDPOINT_EUROPE), + Country("Portugal", "351", ENDPOINT_EUROPE), + Country("Puerto Rico", "1-787, 1-939"), + Country("Qatar", "974"), + Country("Republic of the Congo", "242"), + Country("Reunion", "262"), + Country("Romania", "40", ENDPOINT_EUROPE), + Country("Russia", "7", ENDPOINT_EUROPE), + Country("Rwanda", "250"), + Country("Saint Barthelemy", "590"), + Country("Saint Helena", "290"), + Country("Saint Kitts and Nevis", "1-869"), + Country("Saint Lucia", "1-758"), + Country("Saint Martin", "590"), + Country("Saint Pierre and Miquelon", "508"), + Country("Saint Vincent and the Grenadines", "1-784"), + Country("Samoa", "685"), + Country("San Marino", "378"), + Country("Sao Tome and Principe", "239"), + Country("Saudi Arabia", "966"), + Country("Senegal", "221"), + Country("Serbia", "381"), + Country("Seychelles", "248"), + Country("Sierra Leone", "232"), + Country("Singapore", "65"), + Country("Sint Maarten", "1-721"), + Country("Slovakia", "421", ENDPOINT_EUROPE), + Country("Slovenia", "386", ENDPOINT_EUROPE), + Country("Solomon Islands", "677"), + Country("Somalia", "252"), + Country("South Africa", "27"), + Country("South Korea", "82"), + Country("South Sudan", "211"), + Country("Spain", "34", ENDPOINT_EUROPE), + Country("Sri Lanka", "94"), + Country("Sudan", "249"), + Country("Suriname", "597"), + Country("Svalbard and Jan Mayen", "47", ENDPOINT_EUROPE), + Country("Swaziland", "268"), + Country("Sweden", "46", ENDPOINT_EUROPE), + Country("Switzerland", "41"), + Country("Syria", "963"), + Country("Taiwan", "886"), + Country("Tajikistan", "992"), + Country("Tanzania", "255"), + Country("Thailand", "66"), + Country("Togo", "228"), + Country("Tokelau", "690"), + Country("Tonga", "676"), + Country("Trinidad and Tobago", "1-868"), + Country("Tunisia", "216"), + Country("Turkey", "90"), + Country("Turkmenistan", "993"), + Country("Turks and Caicos Islands", "1-649"), + Country("Tuvalu", "688"), + Country("U.S. Virgin Islands", "1-340"), + Country("Uganda", "256"), + Country("Ukraine", "380"), + Country("United Arab Emirates", "971"), + Country("United Kingdom", "44", ENDPOINT_EUROPE), + Country("United States", "1", ENDPOINT_AMERICA), + Country("Uruguay", "598"), + Country("Uzbekistan", "998"), + Country("Vanuatu", "678"), + Country("Vatican", "379"), + Country("Venezuela", "58"), + Country("Vietnam", "84"), + Country("Wallis and Futuna", "681"), + Country("Western Sahara", "212"), + Country("Yemen", "967"), + Country("Zambia", "260"), + Country("Zimbabwe", "263"), +] diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py deleted file mode 100644 index 08f1d92aca5..00000000000 --- a/homeassistant/components/tuya/cover.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Support for Tuya covers.""" -from datetime import timedelta - -from homeassistant.components.cover import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_STOP, - CoverEntity, -) -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW - -SCAN_INTERVAL = timedelta(seconds=15) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" - - platform = config_entry.data[CONF_PLATFORM] - - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" - if not dev_ids: - return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) - async_add_entities(entities) - - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor - ) - - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) - - -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Cover device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: - continue - entities.append(TuyaCover(device, platform)) - return entities - - -class TuyaCover(TuyaDevice, CoverEntity): - """Tuya cover devices.""" - - def __init__(self, tuya, platform): - """Init tuya cover device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self._was_closing = False - self._was_opening = False - - @property - def supported_features(self): - """Flag supported features.""" - if self._tuya.support_stop(): - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - state = self._tuya.state() - if state == 1: - self._was_opening = True - self._was_closing = False - return True - return False - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - state = self._tuya.state() - if state == 2: - self._was_opening = False - self._was_closing = True - return True - return False - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - state = self._tuya.state() - if state != 2 and self._was_closing: - return True - if state != 1 and self._was_opening: - return False - return None - - def open_cover(self, **kwargs): - """Open the cover.""" - self._tuya.open_cover() - - def close_cover(self, **kwargs): - """Close cover.""" - self._tuya.close_cover() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - if self.is_closed is None: - self._was_opening = False - self._was_closing = False - self._tuya.stop_cover() diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index ab361c6ac31..15a8e553a10 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,141 +1,260 @@ -"""Support for Tuya fans.""" +"""Support for Tuya Fan.""" from __future__ import annotations -from datetime import timedelta +import json +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.fan import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as DEVICE_DOMAIN, + SUPPORT_DIRECTION, SUPPORT_OSCILLATE, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import CONF_PLATFORM, STATE_OFF +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, ) -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from .base import TuyaHaEntity +from .const import ( + DOMAIN, + TUYA_DEVICE_MANAGER, + TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, +) -SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" +# Fan +# https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge +DPCODE_SWITCH = "switch" +DPCODE_FAN_SPEED = "fan_speed_percent" +DPCODE_MODE = "mode" +DPCODE_SWITCH_HORIZONTAL = "switch_horizontal" +DPCODE_FAN_DIRECTION = "fan_direction" - platform = config_entry.data[CONF_PLATFORM] +# Air Purifier +# https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 +DPCODE_AP_FAN_SPEED = "speed" +DPCODE_AP_FAN_SPEED_ENUM = "fan_speed_enum" - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" +TUYA_SUPPORT_TYPE = { + "fs", # Fan + "kj", # Air Purifier +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +): + """Set up tuya fan dynamically through tuya discovery.""" + _LOGGER.debug("fan init") + + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE + + @callback + def async_discover_device(dev_ids: list[str]) -> None: + """Discover and add a discovered tuya fan.""" + _LOGGER.debug("fan add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Fan device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] +def _setup_entities( + hass: HomeAssistant, entry: ConfigEntry, device_ids: list[str] +) -> list[TuyaHaFan]: + """Set up Tuya Fan.""" + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaFanDevice(device, platform)) + entities.append(TuyaHaFan(device, device_manager)) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add(device_id) return entities -class TuyaFanDevice(TuyaDevice, FanEntity): - """Tuya fan devices.""" +class TuyaHaFan(TuyaHaEntity, FanEntity): + """Tuya Fan Device.""" - def __init__(self, tuya, platform): - """Init Tuya fan device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self.speeds = [] + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init Tuya Fan Device.""" + super().__init__(device, device_manager) - async def async_added_to_hass(self): - """Create fan list when add to hass.""" - await super().async_added_to_hass() - self.speeds.extend(self._tuya.speed_list()) + self.ha_preset_modes = [] + if DPCODE_MODE in self.tuya_device.function: + self.ha_preset_modes = json.loads( + self.tuya_device.function[DPCODE_MODE].values + ).get("range", []) + + # Air purifier fan can be controlled either via the ranged values or via the enum. + # We will always prefer the enumeration if available + # Enum is used for e.g. MEES SmartHIMOX-H06 + # Range is used for e.g. Concept CA3000 + self.air_purifier_speed_range_len = 0 + self.air_purifier_speed_range_enum = [] + if self.tuya_device.category == "kj" and ( + DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.function + or DPCODE_AP_FAN_SPEED in self.tuya_device.function + ): + if DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.function: + self.dp_code_speed_enum = DPCODE_AP_FAN_SPEED_ENUM + else: + self.dp_code_speed_enum = DPCODE_AP_FAN_SPEED + + data = json.loads( + self.tuya_device.function[self.dp_code_speed_enum].values + ).get("range") + if data: + self.air_purifier_speed_range_len = len(data) + self.air_purifier_speed_range_enum = data + + def set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + self._send_command([{"code": DPCODE_MODE, "value": preset_mode}]) + + def set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._send_command([{"code": DPCODE_FAN_DIRECTION, "value": direction}]) def set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - if percentage == 0: - self.turn_off() + """Set the speed of the fan, as a percentage.""" + if self.tuya_device.category == "kj": + value_in_range = percentage_to_ordered_list_item( + self.air_purifier_speed_range_enum, percentage + ) + self._send_command( + [ + { + "code": self.dp_code_speed_enum, + "value": value_in_range, + } + ] + ) else: - tuya_speed = percentage_to_ordered_list_item(self.speeds, percentage) - self._tuya.set_speed(tuya_speed) + self._send_command([{"code": DPCODE_FAN_SPEED, "value": percentage}]) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self._send_command([{"code": DPCODE_SWITCH, "value": False}]) def turn_on( self, speed: str = None, percentage: int = None, preset_mode: str = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" - if percentage is not None: - self.set_percentage(percentage) - else: - self._tuya.turn_on() + self._send_command([{"code": DPCODE_SWITCH, "value": True}]) - def turn_off(self, **kwargs) -> None: - """Turn the entity off.""" - self._tuya.turn_off() - - def oscillate(self, oscillating) -> None: + def oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - self._tuya.oscillate(oscillating) + self._send_command([{"code": DPCODE_SWITCH_HORIZONTAL, "value": oscillating}]) @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - if self.speeds is None: - return super().speed_count - return len(self.speeds) + def is_on(self) -> bool: + """Return true if fan is on.""" + return self.tuya_device.status.get(DPCODE_SWITCH, False) @property - def oscillating(self): - """Return current oscillating status.""" - if self.supported_features & SUPPORT_OSCILLATE == 0: - return None - if self.speed == STATE_OFF: - return False - return self._tuya.oscillating() + def current_direction(self) -> str: + """Return the current direction of the fan.""" + if self.tuya_device.status[DPCODE_FAN_DIRECTION]: + return DIRECTION_FORWARD + return DIRECTION_REVERSE @property - def is_on(self): - """Return true if the entity is on.""" - return self._tuya.state() + def oscillating(self) -> bool: + """Return true if the fan is oscillating.""" + return self.tuya_device.status.get(DPCODE_SWITCH_HORIZONTAL, False) + + @property + def preset_modes(self) -> list[str]: + """Return the list of available preset_modes.""" + return self.ha_preset_modes + + @property + def preset_mode(self) -> str: + """Return the current preset_mode.""" + return self.tuya_device.status[DPCODE_MODE] @property def percentage(self) -> int | None: """Return the current speed.""" if not self.is_on: return 0 - if self.speeds is None: - return None - return ordered_list_item_to_percentage(self.speeds, self._tuya.speed()) + + if ( + self.tuya_device.category == "kj" + and self.air_purifier_speed_range_len > 1 + and not self.air_purifier_speed_range_enum + and DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.status + ): + # if air-purifier speed enumeration is supported we will prefer it. + return ordered_list_item_to_percentage( + self.air_purifier_speed_range_enum, + self.tuya_device.status[DPCODE_AP_FAN_SPEED_ENUM], + ) + + # some type may not have the fan_speed_percent key + return self.tuya_device.status.get(DPCODE_FAN_SPEED) @property - def supported_features(self) -> int: + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self.tuya_device.category == "kj": + return self.air_purifier_speed_range_len + return super().speed_count + + @property + def supported_features(self): """Flag supported features.""" - if self._tuya.support_oscillate(): - return SUPPORT_SET_SPEED | SUPPORT_OSCILLATE - return SUPPORT_SET_SPEED + supports = 0 + if DPCODE_MODE in self.tuya_device.status: + supports |= SUPPORT_PRESET_MODE + if DPCODE_FAN_SPEED in self.tuya_device.status: + supports |= SUPPORT_SET_SPEED + if DPCODE_SWITCH_HORIZONTAL in self.tuya_device.status: + supports |= SUPPORT_OSCILLATE + if DPCODE_FAN_DIRECTION in self.tuya_device.status: + supports |= SUPPORT_DIRECTION + + # Air Purifier specific + if ( + DPCODE_AP_FAN_SPEED in self.tuya_device.status + or DPCODE_AP_FAN_SPEED_ENUM in self.tuya_device.status + ): + supports |= SUPPORT_SET_SPEED + return supports diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 4602e65a4d5..6a119e71ba9 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,198 +1,386 @@ """Support for the Tuya lights.""" -from datetime import timedelta +from __future__ import annotations + +import json +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + DOMAIN as DEVICE_DOMAIN, LightEntity, ) -from homeassistant.const import CONF_PLATFORM -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.util import color as colorutil +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TuyaDevice +from .base import TuyaHaEntity from .const import ( - CONF_BRIGHTNESS_RANGE_MODE, - CONF_MAX_KELVIN, - CONF_MIN_KELVIN, - CONF_SUPPORT_COLOR, - CONF_TUYA_MAX_COLTEMP, - DEFAULT_TUYA_MAX_COLTEMP, DOMAIN, - SIGNAL_CONFIG_ENTITY, - TUYA_DATA, + TUYA_DEVICE_MANAGER, TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, ) -SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) -TUYA_BRIGHTNESS_RANGE0 = (1, 255) -TUYA_BRIGHTNESS_RANGE1 = (10, 1000) -BRIGHTNESS_MODES = { - 0: TUYA_BRIGHTNESS_RANGE0, - 1: TUYA_BRIGHTNESS_RANGE1, +# Light(dj) +# https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 +DPCODE_SWITCH = "switch_led" +DPCODE_WORK_MODE = "work_mode" +DPCODE_BRIGHT_VALUE = "bright_value" +DPCODE_TEMP_VALUE = "temp_value" +DPCODE_COLOUR_DATA = "colour_data" +DPCODE_COLOUR_DATA_V2 = "colour_data_v2" +DPCODE_LIGHT = "light" + +MIREDS_MAX = 500 +MIREDS_MIN = 153 + +HSV_HA_HUE_MIN = 0 +HSV_HA_HUE_MAX = 360 +HSV_HA_SATURATION_MIN = 0 +HSV_HA_SATURATION_MAX = 100 + +WORK_MODE_WHITE = "white" +WORK_MODE_COLOUR = "colour" + +TUYA_SUPPORT_TYPE = { + "dj", # Light + "dd", # Light strip + "fwl", # Ambient light + "dc", # Light string + "jsq", # Humidifier's light + "xdd", # Ceiling Light + "xxj", # Diffuser's light + "fs", # Fan +} + +DEFAULT_HSV = { + "h": {"min": 1, "scale": 0, "unit": "", "max": 360, "step": 1}, + "s": {"min": 1, "scale": 0, "unit": "", "max": 255, "step": 1}, + "v": {"min": 1, "scale": 0, "unit": "", "max": 255, "step": 1}, +} + +DEFAULT_HSV_V2 = { + "h": {"min": 1, "scale": 0, "unit": "", "max": 360, "step": 1}, + "s": {"min": 1, "scale": 0, "unit": "", "max": 1000, "step": 1}, + "v": {"min": 1, "scale": 0, "unit": "", "max": 1000, "step": 1}, } -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya light dynamically through tuya discovery.""" + _LOGGER.debug("light init") - platform = config_entry.data[CONF_PLATFORM] + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" + @callback + def async_discover_device(dev_ids: list[str]): + """Discover and add a discovered tuya light.""" + _LOGGER.debug("light add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): +def _setup_entities( + hass, entry: ConfigEntry, device_ids: list[str] +) -> list[TuyaHaLight]: """Set up Tuya Light device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaLight(device, platform)) + + tuya_ha_light = TuyaHaLight(device, device_manager) + entities.append(tuya_ha_light) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add( + tuya_ha_light.tuya_device.id + ) + return entities -class TuyaLight(TuyaDevice, LightEntity): +class TuyaHaLight(TuyaHaEntity, LightEntity): """Tuya light device.""" - def __init__(self, tuya, platform): - """Init Tuya light device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self._min_kelvin = tuya.max_color_temp() - self._max_kelvin = tuya.min_color_temp() + def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + """Init TuyaHaLight.""" + self.dp_code_bright = DPCODE_BRIGHT_VALUE + self.dp_code_temp = DPCODE_TEMP_VALUE + self.dp_code_colour = DPCODE_COLOUR_DATA - @callback - def _process_config(self): - """Set device config parameter.""" - config = self._get_device_config() - if not config: - return + for key in device.function: + if key.startswith(DPCODE_BRIGHT_VALUE): + self.dp_code_bright = key + elif key.startswith(DPCODE_TEMP_VALUE): + self.dp_code_temp = key + elif key.startswith(DPCODE_COLOUR_DATA): + self.dp_code_colour = key - # support color config - supp_color = config.get(CONF_SUPPORT_COLOR, False) - if supp_color: - self._tuya.force_support_color() - # brightness range config - self._tuya.brightness_white_range = BRIGHTNESS_MODES.get( - config.get(CONF_BRIGHTNESS_RANGE_MODE, 0), - TUYA_BRIGHTNESS_RANGE0, - ) - # color set temp range - min_tuya = self._tuya.max_color_temp() - min_kelvin = config.get(CONF_MIN_KELVIN, min_tuya) - max_tuya = self._tuya.min_color_temp() - max_kelvin = config.get(CONF_MAX_KELVIN, max_tuya) - self._min_kelvin = min(max(min_kelvin, min_tuya), max_tuya) - self._max_kelvin = min(max(max_kelvin, self._min_kelvin), max_tuya) - # color shown temp range - max_color_temp = max( - config.get(CONF_TUYA_MAX_COLTEMP, DEFAULT_TUYA_MAX_COLTEMP), - DEFAULT_TUYA_MAX_COLTEMP, - ) - self._tuya.color_temp_range = (1000, max_color_temp) - - async def async_added_to_hass(self): - """Set config parameter when add to hass.""" - await super().async_added_to_hass() - self._process_config() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_CONFIG_ENTITY, self._process_config - ) - ) - return + super().__init__(device, device_manager) @property - def brightness(self): - """Return the brightness of the light.""" - if self._tuya.brightness() is None: - return None - return int(self._tuya.brightness()) - - @property - def hs_color(self): - """Return the hs_color of the light.""" - return tuple(map(int, self._tuya.hs_color())) - - @property - def color_temp(self): - """Return the color_temp of the light.""" - color_temp = int(self._tuya.color_temp()) - if color_temp is None: - return None - return colorutil.color_temperature_kelvin_to_mired(color_temp) - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" - return self._tuya.state() + return self.tuya_device.status.get(DPCODE_SWITCH, False) - @property - def min_mireds(self): - """Return color temperature min mireds.""" - return colorutil.color_temperature_kelvin_to_mired(self._max_kelvin) - - @property - def max_mireds(self): - """Return color temperature max mireds.""" - return colorutil.color_temperature_kelvin_to_mired(self._min_kelvin) - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn on or control the light.""" - if ( - ATTR_BRIGHTNESS not in kwargs - and ATTR_HS_COLOR not in kwargs - and ATTR_COLOR_TEMP not in kwargs - ): - self._tuya.turn_on() - if ATTR_BRIGHTNESS in kwargs: - self._tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) - if ATTR_HS_COLOR in kwargs: - self._tuya.set_color(kwargs[ATTR_HS_COLOR]) - if ATTR_COLOR_TEMP in kwargs: - color_temp = colorutil.color_temperature_mired_to_kelvin( - kwargs[ATTR_COLOR_TEMP] - ) - self._tuya.set_color_temp(color_temp) + commands = [] + _LOGGER.debug("light kwargs-> %s", kwargs) - def turn_off(self, **kwargs): + if ( + DPCODE_LIGHT in self.tuya_device.status + and DPCODE_SWITCH not in self.tuya_device.status + ): + commands += [{"code": DPCODE_LIGHT, "value": True}] + else: + commands += [{"code": DPCODE_SWITCH, "value": True}] + + if ATTR_BRIGHTNESS in kwargs: + if self._work_mode().startswith(WORK_MODE_COLOUR): + colour_data = self._get_hsv() + v_range = self._tuya_hsv_v_range() + colour_data["v"] = int( + self.remap(kwargs[ATTR_BRIGHTNESS], 0, 255, v_range[0], v_range[1]) + ) + commands += [ + {"code": self.dp_code_colour, "value": json.dumps(colour_data)} + ] + else: + new_range = self._tuya_brightness_range() + tuya_brightness = int( + self.remap( + kwargs[ATTR_BRIGHTNESS], 0, 255, new_range[0], new_range[1] + ) + ) + commands += [{"code": self.dp_code_bright, "value": tuya_brightness}] + + if ATTR_HS_COLOR in kwargs: + colour_data = self._get_hsv() + # hsv h + colour_data["h"] = int(kwargs[ATTR_HS_COLOR][0]) + # hsv s + ha_s = kwargs[ATTR_HS_COLOR][1] + s_range = self._tuya_hsv_s_range() + colour_data["s"] = int( + self.remap( + ha_s, + HSV_HA_SATURATION_MIN, + HSV_HA_SATURATION_MAX, + s_range[0], + s_range[1], + ) + ) + # hsv v + ha_v = self.brightness + v_range = self._tuya_hsv_v_range() + colour_data["v"] = int(self.remap(ha_v, 0, 255, v_range[0], v_range[1])) + + commands += [ + {"code": self.dp_code_colour, "value": json.dumps(colour_data)} + ] + if self.tuya_device.status[DPCODE_WORK_MODE] != "colour": + commands += [{"code": DPCODE_WORK_MODE, "value": "colour"}] + + if ATTR_COLOR_TEMP in kwargs: + # temp color + new_range = self._tuya_temp_range() + color_temp = self.remap( + self.max_mireds - kwargs[ATTR_COLOR_TEMP] + self.min_mireds, + self.min_mireds, + self.max_mireds, + new_range[0], + new_range[1], + ) + commands += [{"code": self.dp_code_temp, "value": int(color_temp)}] + + # brightness + ha_brightness = self.brightness + new_range = self._tuya_brightness_range() + tuya_brightness = self.remap( + ha_brightness, 0, 255, new_range[0], new_range[1] + ) + commands += [{"code": self.dp_code_bright, "value": int(tuya_brightness)}] + + if self.tuya_device.status[DPCODE_WORK_MODE] != "white": + commands += [{"code": DPCODE_WORK_MODE, "value": "white"}] + + self._send_command(commands) + + def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - self._tuya.turn_off() + if ( + DPCODE_LIGHT in self.tuya_device.status + and DPCODE_SWITCH not in self.tuya_device.status + ): + commands = [{"code": DPCODE_LIGHT, "value": False}] + else: + commands = [{"code": DPCODE_SWITCH, "value": False}] + self._send_command(commands) @property - def supported_features(self): - """Flag supported features.""" - supports = SUPPORT_BRIGHTNESS - if self._tuya.support_color(): - supports = supports | SUPPORT_COLOR - if self._tuya.support_color_temp(): - supports = supports | SUPPORT_COLOR_TEMP - return supports + def brightness(self) -> int | None: + """Return the brightness of the light.""" + old_range = self._tuya_brightness_range() + brightness = self.tuya_device.status.get(self.dp_code_bright, 0) + + if self._work_mode().startswith(WORK_MODE_COLOUR): + colour_json = self.tuya_device.status.get(self.dp_code_colour) + if not colour_json: + return None + colour_data = json.loads(colour_json) + v_range = self._tuya_hsv_v_range() + hsv_v = colour_data.get("v", 0) + return int(self.remap(hsv_v, v_range[0], v_range[1], 0, 255)) + + return int(self.remap(brightness, old_range[0], old_range[1], 0, 255)) + + def _tuya_brightness_range(self) -> tuple[int, int]: + if self.dp_code_bright not in self.tuya_device.status: + return 0, 255 + bright_item = self.tuya_device.function.get(self.dp_code_bright) + if not bright_item: + return 0, 255 + bright_value = json.loads(bright_item.values) + return bright_value.get("min", 0), bright_value.get("max", 255) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hs_color of the light.""" + colour_json = self.tuya_device.status.get(self.dp_code_colour) + if not colour_json: + return None + colour_data = json.loads(colour_json) + s_range = self._tuya_hsv_s_range() + return colour_data.get("h", 0), self.remap( + colour_data.get("s", 0), + s_range[0], + s_range[1], + HSV_HA_SATURATION_MIN, + HSV_HA_SATURATION_MAX, + ) + + @property + def color_temp(self) -> int: + """Return the color_temp of the light.""" + new_range = self._tuya_temp_range() + tuya_color_temp = self.tuya_device.status.get(self.dp_code_temp, 0) + return ( + self.max_mireds + - self.remap( + tuya_color_temp, + new_range[0], + new_range[1], + self.min_mireds, + self.max_mireds, + ) + + self.min_mireds + ) + + @property + def min_mireds(self) -> int: + """Return color temperature min mireds.""" + return MIREDS_MIN + + @property + def max_mireds(self) -> int: + """Return color temperature max mireds.""" + return MIREDS_MAX + + def _tuya_temp_range(self) -> tuple[int, int]: + temp_item = self.tuya_device.function.get(self.dp_code_temp) + if not temp_item: + return 0, 255 + temp_value = json.loads(temp_item.values) + return temp_value.get("min", 0), temp_value.get("max", 255) + + def _tuya_hsv_s_range(self) -> tuple[int, int]: + hsv_data_range = self._tuya_hsv_function() + if hsv_data_range is not None: + hsv_s = hsv_data_range.get("s", {"min": 0, "max": 255}) + return hsv_s.get("min", 0), hsv_s.get("max", 255) + return 0, 255 + + def _tuya_hsv_v_range(self) -> tuple[int, int]: + hsv_data_range = self._tuya_hsv_function() + if hsv_data_range is not None: + hsv_v = hsv_data_range.get("v", {"min": 0, "max": 255}) + return hsv_v.get("min", 0), hsv_v.get("max", 255) + + return 0, 255 + + def _tuya_hsv_function(self) -> dict[str, dict] | None: + hsv_item = self.tuya_device.function.get(self.dp_code_colour) + if not hsv_item: + return None + hsv_data = json.loads(hsv_item.values) + if hsv_data: + return hsv_data + colour_json = self.tuya_device.status.get(self.dp_code_colour) + if not colour_json: + return None + colour_data = json.loads(colour_json) + if ( + self.dp_code_colour == DPCODE_COLOUR_DATA_V2 + or colour_data.get("v", 0) > 255 + or colour_data.get("s", 0) > 255 + ): + return DEFAULT_HSV_V2 + return DEFAULT_HSV + + def _work_mode(self) -> str: + return self.tuya_device.status.get(DPCODE_WORK_MODE, "") + + def _get_hsv(self) -> dict[str, int]: + return json.loads(self.tuya_device.status[self.dp_code_colour]) + + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + color_modes = [COLOR_MODE_ONOFF] + if self.dp_code_bright in self.tuya_device.status: + color_modes.append(COLOR_MODE_BRIGHTNESS) + + if self.dp_code_temp in self.tuya_device.status: + color_modes.append(COLOR_MODE_COLOR_TEMP) + + if ( + self.dp_code_colour in self.tuya_device.status + and len(self.tuya_device.status[self.dp_code_colour]) > 0 + ): + color_modes.append(COLOR_MODE_HS) + return set(color_modes) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 5dae8e6a101..20df33f4573 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -1,17 +1,9 @@ { "domain": "tuya", "name": "Tuya", - "documentation": "https://www.home-assistant.io/integrations/tuya", - "requirements": ["tuyaha==0.0.10"], - "codeowners": ["@ollo69"], + "documentation": "https://github.com/tuya/tuya-home-assistant", + "requirements": ["tuya-iot-py-sdk==0.5.0"], + "codeowners": ["@Tuya", "@zlinoliver", "@METISU"], "config_flow": true, - "iot_class": "cloud_polling", - "dhcp": [ - {"macaddress": "508A06*"}, - {"macaddress": "7CF666*"}, - {"macaddress": "10D561*"}, - {"macaddress": "D4A651*"}, - {"macaddress": "68572D*"}, - {"macaddress": "1869D8*"} - ] + "iot_class": "cloud_push" } diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 430b2bc7e27..c90c6798b9b 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -1,61 +1,69 @@ -"""Support for the Tuya scenes.""" +"""Support for Tuya scenes.""" +from __future__ import annotations + +import logging from typing import Any -from homeassistant.components.scene import DOMAIN as SENSOR_DOMAIN, Scene -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from tuya_iot import TuyaHomeManager, TuyaScene -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" +from .const import DOMAIN, TUYA_HOME_MANAGER + +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up tuya sensors dynamically through tuya discovery.""" - - platform = config_entry.data[CONF_PLATFORM] - - async def async_discover_sensor(dev_ids): - """Discover and add a discovered tuya sensor.""" - if not dev_ids: - return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) - async_add_entities(entities) - - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor - ) - - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up tuya scenes.""" + home_manager = hass.data[DOMAIN][entry.entry_id][TUYA_HOME_MANAGER] + scenes = await hass.async_add_executor_job(home_manager.query_scenes) + async_add_entities(TuyaHAScene(home_manager, scene) for scene in scenes) -def _setup_entities(hass, dev_ids, platform): - """Set up Tuya Scene.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: - continue - entities.append(TuyaScene(device, platform)) - return entities +class TuyaHAScene(Scene): + """Tuya Scene Remote.""" + def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: + """Init Tuya Scene.""" + super().__init__() + self.home_manager = home_manager + self.scene = scene -class TuyaScene(TuyaDevice, Scene): - """Tuya Scene.""" + @property + def should_poll(self) -> bool: + """Hass should not poll.""" + return False - def __init__(self, tuya, platform): - """Init Tuya scene.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"tys{self.scene.scene_id}" + + @property + def name(self) -> str | None: + """Return Tuya scene name.""" + return self.scene.name + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, f"{self.unique_id}")}, + "manufacturer": "tuya", + "name": self.scene.name, + "model": "Tuya Scene", + } + + @property + def available(self) -> bool: + """Return if the scene is enabled.""" + return self.scene.enabled def activate(self, **kwargs: Any) -> None: """Activate the scene.""" - self._tuya.activate() + self.home_manager.trigger_scene(self.scene.home_id, self.scene.scene_id) diff --git a/homeassistant/components/tuya/services.yaml b/homeassistant/components/tuya/services.yaml deleted file mode 100644 index 42fba3ad37b..00000000000 --- a/homeassistant/components/tuya/services.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Describes the format for available Tuya services - -pull_devices: - name: Pull devices - description: Pull device list from Tuya server. - -force_update: - name: Force update - description: Force all Tuya devices to pull data. diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 61ea46c6a9f..0bb59615e6e 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -2,63 +2,19 @@ "config": { "step": { "user": { - "title": "Tuya", - "description": "Enter your Tuya credentials.", + "description": "Enter your Tuya credentials", "data": { - "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", - "password": "[%key:common::config_flow::data::password%]", - "platform": "The app where your account is registered", - "username": "[%key:common::config_flow::data::username%]" + "country_code": "Country", + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", + "username": "Account", + "password": "[%key:common::config_flow::data::password%]" } } }, - "abort": { + "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" - } - }, - "options": { - "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, - "step": { - "init": { - "title": "Configure Tuya Options", - "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", - "data": { - "discovery_interval": "Discovery device polling interval in seconds", - "query_device": "Select device that will use query method for faster status update", - "query_interval": "Query device polling interval in seconds", - "list_devices": "Select the devices to configure or leave empty to save configuration" - } - }, - "device": { - "title": "Configure Tuya Device", - "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", - "data": { - "support_color": "Force color support", - "brightness_range_mode": "Brightness range used by device", - "min_kelvin": "Min color temperature supported in kelvin", - "max_kelvin": "Max color temperature supported in kelvin", - "tuya_max_coltemp": "Max color temperature reported by device", - "unit_of_measurement": "Temperature unit used by device", - "temp_divider": "Temperature values divider (0 = use default)", - "curr_temp_divider": "Current Temperature value divider (0 = use default)", - "set_temp_divided": "Use divided Temperature value for set temperature command", - "temp_step_override": "Target Temperature step", - "min_temp": "Min target temperature (use min and max = 0 for default)", - "max_temp": "Max target temperature (use min and max = 0 for default)" - } - } - }, - "error": { - "dev_multi_type": "Multiple selected devices to configure must be of the same type", - "dev_not_config": "Device type not configurable", - "dev_not_found": "Device not found" + "login_error": "Login error ({code}): {msg}" } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 3f5ff6db163..5bafbe1b7f6 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,74 +1,176 @@ """Support for Tuya switches.""" -from datetime import timedelta +from __future__ import annotations -from homeassistant.components.switch import ( - DOMAIN as SENSOR_DOMAIN, - ENTITY_ID_FORMAT, - SwitchEntity, -) -from homeassistant.const import CONF_PLATFORM +import logging +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.switch import DOMAIN as DEVICE_DOMAIN, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from .base import TuyaHaEntity +from .const import ( + DOMAIN, + TUYA_DEVICE_MANAGER, + TUYA_DISCOVERY_NEW, + TUYA_HA_DEVICES, + TUYA_HA_TUYA_MAP, +) -SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) + +TUYA_SUPPORT_TYPE = { + "kg", # Switch + "cz", # Socket + "pc", # Power Strip + "bh", # Smart Kettle + "dlq", # Breaker + "cwysj", # Pet Water Feeder + "kj", # Air Purifier + "xxj", # Diffuser +} + +# Switch(kg), Socket(cz), Power Strip(pc) +# https://developer.tuya.com/en/docs/iot/categorykgczpc?id=Kaiuz08zj1l4y +DPCODE_SWITCH = "switch" + +# Air Purifier +# https://developer.tuya.com/en/docs/iot/categorykj?id=Kaiuz1atqo5l7 +# Pet Water Feeder +# https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 +DPCODE_ANION = "anion" # Air Purifier - Ionizer unit +# Air Purifier - Filter cartridge resetting; Pet Water Feeder - Filter cartridge resetting +DPCODE_FRESET = "filter_reset" +DPCODE_LIGHT = "light" # Air Purifier - Light +DPCODE_LOCK = "lock" # Air Purifier - Child lock +# Air Purifier - UV sterilization; Pet Water Feeder - UV sterilization +DPCODE_UV = "uv" +DPCODE_WET = "wet" # Air Purifier - Humidification unit +DPCODE_PRESET = "pump_reset" # Pet Water Feeder - Water pump resetting +DPCODE_WRESET = "water_reset" # Pet Water Feeder - Resetting of water usage days -async def async_setup_entry(hass, config_entry, async_add_entities): +DPCODE_START = "start" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up tuya sensors dynamically through tuya discovery.""" + _LOGGER.debug("switch init") - platform = config_entry.data[CONF_PLATFORM] + hass.data[DOMAIN][entry.entry_id][TUYA_HA_TUYA_MAP][ + DEVICE_DOMAIN + ] = TUYA_SUPPORT_TYPE - async def async_discover_sensor(dev_ids): + async def async_discover_device(dev_ids): """Discover and add a discovered tuya sensor.""" + _LOGGER.debug("switch add-> %s", dev_ids) if not dev_ids: return - entities = await hass.async_add_executor_job( - _setup_entities, - hass, - dev_ids, - platform, - ) + entities = _setup_entities(hass, entry, dev_ids) async_add_entities(entities) - async_dispatcher_connect( - hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + entry.async_on_unload( + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(DEVICE_DOMAIN), async_discover_device + ) ) - devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) - await async_discover_sensor(devices_ids) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + device_ids = [] + for (device_id, device) in device_manager.device_map.items(): + if device.category in TUYA_SUPPORT_TYPE: + device_ids.append(device_id) + await async_discover_device(device_ids) -def _setup_entities(hass, dev_ids, platform): +def _setup_entities(hass, entry: ConfigEntry, device_ids: list[str]) -> list[Entity]: """Set up Tuya Switch device.""" - tuya = hass.data[DOMAIN][TUYA_DATA] - entities = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) + device_manager = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE_MANAGER] + entities: list[Entity] = [] + for device_id in device_ids: + device = device_manager.device_map[device_id] if device is None: continue - entities.append(TuyaSwitch(device, platform)) + + for function in device.function: + tuya_ha_switch = None + if device.category == "kj": + if function in [ + DPCODE_ANION, + DPCODE_FRESET, + DPCODE_LIGHT, + DPCODE_LOCK, + DPCODE_UV, + DPCODE_WET, + ]: + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + # Main device switch is handled by the Fan object + elif device.category == "cwysj": + if function in [DPCODE_FRESET, DPCODE_UV, DPCODE_PRESET, DPCODE_WRESET]: + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + + if function.startswith(DPCODE_SWITCH): + # Main device switch + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + else: + if function.startswith(DPCODE_START): + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + if function.startswith(DPCODE_SWITCH): + tuya_ha_switch = TuyaHaSwitch(device, device_manager, function) + + if tuya_ha_switch is not None: + entities.append(tuya_ha_switch) + hass.data[DOMAIN][entry.entry_id][TUYA_HA_DEVICES].add( + tuya_ha_switch.tuya_device.id + ) return entities -class TuyaSwitch(TuyaDevice, SwitchEntity): +class TuyaHaSwitch(TuyaHaEntity, SwitchEntity): """Tuya Switch Device.""" - def __init__(self, tuya, platform): - """Init Tuya switch device.""" - super().__init__(tuya, platform) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + dp_code_switch = DPCODE_SWITCH + dp_code_start = DPCODE_START + + def __init__( + self, device: TuyaDevice, device_manager: TuyaDeviceManager, dp_code: str = "" + ) -> None: + """Init TuyaHaSwitch.""" + super().__init__(device, device_manager) + + self.dp_code = dp_code + self.channel = ( + dp_code.replace(DPCODE_SWITCH, "") + if dp_code.startswith(DPCODE_SWITCH) + else dp_code + ) @property - def is_on(self): + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"{super().unique_id}{self.channel}" + + @property + def name(self) -> str | None: + """Return Tuya device name.""" + return f"{self.tuya_device.name}{self.channel}" + + @property + def is_on(self) -> bool: """Return true if switch is on.""" - return self._tuya.state() + return self.tuya_device.status.get(self.dp_code, False) - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - self._tuya.turn_on() + self._send_command([{"code": self.dp_code, "value": True}]) - def turn_off(self, **kwargs): - """Turn the device off.""" - self._tuya.turn_off() + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._send_command([{"code": self.dp_code, "value": False}]) diff --git a/homeassistant/components/tuya/translations/ca.json b/homeassistant/components/tuya/translations/ca.json index 62fad2ad47f..bff9b7a35b9 100644 --- a/homeassistant/components/tuya/translations/ca.json +++ b/homeassistant/components/tuya/translations/ca.json @@ -6,19 +6,37 @@ "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "login_error": "Error d'inici de sessi\u00f3 ({code}): {msg}" }, "flow_title": "Configuraci\u00f3 de Tuya", "step": { + "login": { + "data": { + "access_id": "ID d'acc\u00e9s", + "access_secret": "Secret d'acc\u00e9s", + "country_code": "Codi de pa\u00eds", + "endpoint": "Zona de disponibilitat", + "password": "Contrasenya", + "tuya_app_type": "Aplicaci\u00f3 per a m\u00f2bil", + "username": "Compte" + }, + "description": "Introdueix la credencial de Tuya", + "title": "Tuya" + }, "user": { "data": { + "access_id": "ID d'acc\u00e9s de Tuya IoT", + "access_secret": "Secret d'acc\u00e9s de Tuya IoT", "country_code": "El teu codi de pa\u00eds (per exemple, 1 per l'EUA o 86 per la Xina)", "password": "Contrasenya", "platform": "L'aplicaci\u00f3 on es registra el teu compte", - "username": "Nom d'usuari" + "region": "Regi\u00f3", + "tuya_project_type": "Tipus de projecte al n\u00favol de Tuya", + "username": "Compte" }, - "description": "Introdueix les teves credencial de Tuya.", - "title": "Tuya" + "description": "Introdueix les teves credencial de Tuya", + "title": "Integraci\u00f3 Tuya" } } }, diff --git a/homeassistant/components/tuya/translations/de.json b/homeassistant/components/tuya/translations/de.json index 54fd3de7cbf..57439e1fa76 100644 --- a/homeassistant/components/tuya/translations/de.json +++ b/homeassistant/components/tuya/translations/de.json @@ -10,15 +10,29 @@ }, "flow_title": "Tuya Konfiguration", "step": { + "login": { + "data": { + "access_id": "Zugangs-ID", + "access_secret": "Zugangsgeheimnis", + "country_code": "L\u00e4ndercode", + "endpoint": "Verf\u00fcgbarkeitszone", + "password": "Passwort", + "tuya_app_type": "Mobile App", + "username": "Konto" + }, + "description": "Gib deine Tuya-Anmeldedaten ein", + "title": "Tuya" + }, "user": { "data": { "country_code": "L\u00e4ndercode deines Kontos (z. B. 1 f\u00fcr USA oder 86 f\u00fcr China)", "password": "Passwort", "platform": "Die App, in der dein Konto registriert ist", + "tuya_project_type": "Tuya Cloud Projekttyp", "username": "Benutzername" }, "description": "Gib deine Tuya-Anmeldeinformationen ein.", - "title": "Tuya" + "title": "Tuya-Integration" } } }, diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index ee304ff30cd..e69872fd309 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,64 +1,19 @@ { "config": { - "abort": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, "error": { - "invalid_auth": "Invalid authentication" + "invalid_auth": "Invalid authentication", + "login_error": "Login error ({code}): {msg}" }, - "flow_title": "Tuya configuration", "step": { "user": { "data": { - "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", + "country_code": "Country", "password": "Password", - "platform": "The app where your account is registered", - "username": "Username" + "username": "Account" }, - "description": "Enter your Tuya credentials.", - "title": "Tuya" - } - } - }, - "options": { - "abort": { - "cannot_connect": "Failed to connect" - }, - "error": { - "dev_multi_type": "Multiple selected devices to configure must be of the same type", - "dev_not_config": "Device type not configurable", - "dev_not_found": "Device not found" - }, - "step": { - "device": { - "data": { - "brightness_range_mode": "Brightness range used by device", - "curr_temp_divider": "Current Temperature value divider (0 = use default)", - "max_kelvin": "Max color temperature supported in kelvin", - "max_temp": "Max target temperature (use min and max = 0 for default)", - "min_kelvin": "Min color temperature supported in kelvin", - "min_temp": "Min target temperature (use min and max = 0 for default)", - "set_temp_divided": "Use divided Temperature value for set temperature command", - "support_color": "Force color support", - "temp_divider": "Temperature values divider (0 = use default)", - "temp_step_override": "Target Temperature step", - "tuya_max_coltemp": "Max color temperature reported by device", - "unit_of_measurement": "Temperature unit used by device" - }, - "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", - "title": "Configure Tuya Device" - }, - "init": { - "data": { - "discovery_interval": "Discovery device polling interval in seconds", - "list_devices": "Select the devices to configure or leave empty to save configuration", - "query_device": "Select device that will use query method for faster status update", - "query_interval": "Query device polling interval in seconds" - }, - "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", - "title": "Configure Tuya Options" + "description": "Enter your Tuya credentials" } } } diff --git a/homeassistant/components/tuya/translations/es.json b/homeassistant/components/tuya/translations/es.json index 9c57a216888..74649379a7c 100644 --- a/homeassistant/components/tuya/translations/es.json +++ b/homeassistant/components/tuya/translations/es.json @@ -10,11 +10,25 @@ }, "flow_title": "Configuraci\u00f3n Tuya", "step": { + "login": { + "data": { + "access_id": "ID de acceso", + "access_secret": "Acceso secreto", + "country_code": "C\u00f3digo de pa\u00eds", + "endpoint": "Zona de disponibilidad", + "password": "Contrase\u00f1a", + "tuya_app_type": "Aplicaci\u00f3n m\u00f3vil", + "username": "Cuenta" + }, + "description": "Ingrese su credencial Tuya", + "title": "Tuya" + }, "user": { "data": { "country_code": "C\u00f3digo de pais de tu cuenta (por ejemplo, 1 para USA o 86 para China)", "password": "Contrase\u00f1a", "platform": "La aplicaci\u00f3n en la cual registraste tu cuenta", + "tuya_project_type": "Tipo de proyecto en la nube de Tuya", "username": "Usuario" }, "description": "Introduce tu credencial Tuya.", diff --git a/homeassistant/components/tuya/translations/et.json b/homeassistant/components/tuya/translations/et.json index 48161f552b8..96f081621b8 100644 --- a/homeassistant/components/tuya/translations/et.json +++ b/homeassistant/components/tuya/translations/et.json @@ -6,19 +6,37 @@ "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks sidumine." }, "error": { - "invalid_auth": "Tuvastamise viga" + "invalid_auth": "Tuvastamise viga", + "login_error": "Sisenemine nurjus ( {code} ): {msg}" }, "flow_title": "Tuya seaded", "step": { + "login": { + "data": { + "access_id": "Juurdep\u00e4\u00e4su ID", + "access_secret": "API salas\u00f5na", + "country_code": "Riigi kood", + "endpoint": "Seadmete regioon", + "password": "Salas\u00f5na", + "tuya_app_type": "Mobiilirakendus", + "username": "Konto" + }, + "description": "Sisesta oma Tuya mandaat", + "title": "Tuya" + }, "user": { "data": { + "access_id": "Tuya IoT kasutajatunnus", + "access_secret": "Tuya IoT salas\u00f5na", "country_code": "Konto riigikood (nt 1 USA v\u00f5i 372 Eesti)", "password": "Salas\u00f5na", "platform": "\u00c4pp kus konto registreeriti", + "region": "Piirkond", + "tuya_project_type": "Tuya pilveprojekti t\u00fc\u00fcp", "username": "Kasutajanimi" }, "description": "Sisesta oma Tuya konto andmed.", - "title": "" + "title": "Tuya sidumine" } } }, diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json index 1681343f3b7..b741d3f9377 100644 --- a/homeassistant/components/tuya/translations/fr.json +++ b/homeassistant/components/tuya/translations/fr.json @@ -24,7 +24,7 @@ }, "options": { "abort": { - "cannot_connect": "Impossible de se connecter" + "cannot_connect": "\u00c9chec de connexion" }, "error": { "dev_multi_type": "Plusieurs p\u00e9riph\u00e9riques s\u00e9lectionn\u00e9s \u00e0 configurer doivent \u00eatre du m\u00eame type", diff --git a/homeassistant/components/tuya/translations/he.json b/homeassistant/components/tuya/translations/he.json index 45980842a75..548e623533a 100644 --- a/homeassistant/components/tuya/translations/he.json +++ b/homeassistant/components/tuya/translations/he.json @@ -8,23 +8,53 @@ "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, - "flow_title": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d8\u05d5\u05d9\u05d4", + "flow_title": "\u05ea\u05e6\u05d5\u05e8\u05ea Tuya", "step": { + "login": { + "data": { + "access_id": "\u05de\u05d6\u05d4\u05d4 \u05d2\u05d9\u05e9\u05d4", + "access_secret": "\u05e1\u05d5\u05d3 \u05d2\u05d9\u05e9\u05d4", + "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05d4", + "endpoint": "\u05d0\u05d6\u05d5\u05e8 \u05d6\u05de\u05d9\u05e0\u05d5\u05ea", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "tuya_app_type": "\u05d9\u05d9\u05e9\u05d5\u05dd \u05dc\u05e0\u05d9\u05d9\u05d3", + "username": "\u05d7\u05e9\u05d1\u05d5\u05df" + }, + "description": "\u05d4\u05d6\u05e0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 \u05d4-Tuya \u05e9\u05dc\u05da", + "title": "Tuya" + }, "user": { "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)", + "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05ea \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\u05d9\u05d9\u05e9\u05d5\u05dd \u05d1\u05d5 \u05e8\u05e9\u05d5\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da", + "tuya_project_type": "\u05e1\u05d5\u05d2 \u05e4\u05e8\u05d5\u05d9\u05d9\u05e7\u05d8 \u05d4\u05e2\u05e0\u05df \u05e9\u05dc Tuya", "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.", - "title": "Tuya" + "description": "\u05d4\u05d6\u05e0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05d4-Tuya \u05e9\u05dc\u05da.", + "title": "\u05e9\u05d9\u05dc\u05d5\u05d1 Tuya" } } }, "options": { "abort": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "dev_multi_type": "\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05e0\u05d1\u05d7\u05e8\u05d9\u05dd \u05de\u05e8\u05d5\u05d1\u05d9\u05dd \u05dc\u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05d0\u05d5\u05ea\u05d5 \u05e1\u05d5\u05d2", + "dev_not_config": "\u05e1\u05d5\u05d2 \u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d3\u05e8\u05d4", + "dev_not_found": "\u05d4\u05d4\u05ea\u05e7\u05df \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u05d8\u05d5\u05d5\u05d7 \u05d1\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df", + "max_kelvin": "\u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05ea \u05e6\u05d1\u05e2 \u05de\u05e8\u05d1\u05d9\u05ea \u05d4\u05e0\u05ea\u05de\u05db\u05ea \u05d1\u05e7\u05dc\u05d5\u05d5\u05d9\u05df", + "min_kelvin": "\u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05ea \u05e6\u05d1\u05e2 \u05de\u05d9\u05e0\u05d9\u05de\u05dc\u05d9\u05ea \u05d4\u05e0\u05ea\u05de\u05db\u05ea \u05d1\u05e7\u05dc\u05d5\u05d5\u05d9\u05df", + "support_color": "\u05db\u05e4\u05d4 \u05ea\u05de\u05d9\u05db\u05d4 \u05d1\u05e6\u05d1\u05e2", + "unit_of_measurement": "\u05d9\u05d7\u05d9\u05d3\u05ea \u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4 \u05d4\u05de\u05e9\u05de\u05e9\u05ea \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/hu.json b/homeassistant/components/tuya/translations/hu.json index 054e6443d2a..d721a8cd133 100644 --- a/homeassistant/components/tuya/translations/hu.json +++ b/homeassistant/components/tuya/translations/hu.json @@ -10,11 +10,25 @@ }, "flow_title": "Tuya konfigur\u00e1ci\u00f3", "step": { + "login": { + "data": { + "access_id": "Hozz\u00e1f\u00e9r\u00e9si azonos\u00edt\u00f3", + "access_secret": "Hozz\u00e1f\u00e9r\u00e9si token", + "country_code": "Orsz\u00e1g k\u00f3d", + "endpoint": "El\u00e9rhet\u0151s\u00e9gi z\u00f3na", + "password": "Jelsz\u00f3", + "tuya_app_type": "Mobil app", + "username": "Fi\u00f3k" + }, + "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", + "title": "Tuya" + }, "user": { "data": { "country_code": "A fi\u00f3k orsz\u00e1gk\u00f3dja (pl. 1 USA, 36 Magyarorsz\u00e1g, vagy 86 K\u00edna)", "password": "Jelsz\u00f3", "platform": "Az alkalmaz\u00e1s, ahol a fi\u00f3k regisztr\u00e1lt", + "tuya_project_type": "Tuya felh\u0151 projekt t\u00edpusa", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", @@ -27,7 +41,7 @@ "cannot_connect": "A kapcsol\u00f3d\u00e1s nem siker\u00fclt" }, "error": { - "dev_multi_type": "T\u00f6bb kiv\u00e1lasztott konfigur\u00e1land\u00f3 eszk\u00f6znek azonos t\u00edpus\u00fanak kell lennie", + "dev_multi_type": "T\u00f6bb kiv\u00e1lasztott konfigur\u00e1land\u00f3 eszk\u00f6z eset\u00e9n, azonos t\u00edpus\u00fanak kell lennie", "dev_not_config": "Ez az eszk\u00f6zt\u00edpus nem konfigur\u00e1lhat\u00f3", "dev_not_found": "Eszk\u00f6z nem tal\u00e1lhat\u00f3" }, @@ -37,9 +51,9 @@ "brightness_range_mode": "Az eszk\u00f6z \u00e1ltal haszn\u00e1lt f\u00e9nyer\u0151 tartom\u00e1ny", "curr_temp_divider": "Aktu\u00e1lis h\u0151m\u00e9rs\u00e9klet-\u00e9rt\u00e9k oszt\u00f3 (0 = alap\u00e9rtelmezetten)", "max_kelvin": "Maxim\u00e1lis t\u00e1mogatott sz\u00ednh\u0151m\u00e9rs\u00e9klet kelvinben", - "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)", + "max_temp": "Maxim\u00e1lis k\u00edv\u00e1nt h\u0151m\u00e9rs\u00e9klet (alap\u00e9rtelmezettnek min \u00e9s max 0)", "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)", + "min_temp": "Minim\u00e1lis k\u00edv\u00e1nt h\u0151m\u00e9rs\u00e9klet (alap\u00e9rtelmezettnek min \u00e9s max 0)", "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)", @@ -53,8 +67,8 @@ "init": { "data": { "discovery_interval": "Felfedez\u0151 eszk\u00f6z lek\u00e9rdez\u00e9si intervalluma m\u00e1sodpercben", - "list_devices": "V\u00e1laszd ki a konfigur\u00e1lni k\u00edv\u00e1nt eszk\u00f6z\u00f6ket, vagy hagyd \u00fcresen a konfigur\u00e1ci\u00f3 ment\u00e9s\u00e9hez", - "query_device": "V\u00e1laszd ki azt az eszk\u00f6zt, amely a lek\u00e9rdez\u00e9si m\u00f3dszert haszn\u00e1lja a gyorsabb \u00e1llapotfriss\u00edt\u00e9shez", + "list_devices": "V\u00e1lassza ki a konfigur\u00e1lni k\u00edv\u00e1nt eszk\u00f6z\u00f6ket, vagy hagyja \u00fcresen a konfigur\u00e1ci\u00f3 ment\u00e9s\u00e9hez", + "query_device": "V\u00e1lassza ki azt az eszk\u00f6zt, amely a lek\u00e9rdez\u00e9si m\u00f3dszert haszn\u00e1lja a gyorsabb \u00e1llapotfriss\u00edt\u00e9shez", "query_interval": "Eszk\u00f6z lek\u00e9rdez\u00e9si id\u0151k\u00f6ze m\u00e1sodpercben" }, "description": "Ne \u00e1ll\u00edtsd t\u00fal alacsonyra a lek\u00e9rdez\u00e9si intervallum \u00e9rt\u00e9keit, k\u00fcl\u00f6nben a h\u00edv\u00e1sok nem fognak hiba\u00fczenetet gener\u00e1lni a napl\u00f3ban", diff --git a/homeassistant/components/tuya/translations/id.json b/homeassistant/components/tuya/translations/id.json index bb338e12752..8b7f196b5a2 100644 --- a/homeassistant/components/tuya/translations/id.json +++ b/homeassistant/components/tuya/translations/id.json @@ -14,7 +14,7 @@ "data": { "country_code": "Kode negara akun Anda (mis., 1 untuk AS atau 86 untuk China)", "password": "Kata Sandi", - "platform": "Aplikasi tempat akun Anda mendaftar", + "platform": "Aplikasi tempat akun Anda terdaftar", "username": "Nama Pengguna" }, "description": "Masukkan kredensial Tuya Anda.", diff --git a/homeassistant/components/tuya/translations/it.json b/homeassistant/components/tuya/translations/it.json index a2a8dc87473..9f3b7d498e3 100644 --- a/homeassistant/components/tuya/translations/it.json +++ b/homeassistant/components/tuya/translations/it.json @@ -6,19 +6,37 @@ "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { - "invalid_auth": "Autenticazione non valida" + "invalid_auth": "Autenticazione non valida", + "login_error": "Errore di accesso ({code}): {msg}" }, "flow_title": "Configurazione di Tuya", "step": { + "login": { + "data": { + "access_id": "ID di accesso", + "access_secret": "Accesso segreto", + "country_code": "Prefisso internazionale", + "endpoint": "Zona di disponibilit\u00e0", + "password": "Password", + "tuya_app_type": "App per dispositivi mobili", + "username": "Account" + }, + "description": "Inserisci le tue credenziali Tuya", + "title": "Tuya" + }, "user": { "data": { + "access_id": "ID accesso IoT Tuya", + "access_secret": "Secret IoT Tuya", "country_code": "Prefisso internazionale del tuo account (ad es. 1 per gli Stati Uniti o 86 per la Cina)", "password": "Password", "platform": "L'app in cui \u00e8 registrato il tuo account", + "region": "Area geografica", + "tuya_project_type": "Tipo di progetto Tuya cloud", "username": "Nome utente" }, "description": "Inserisci le tue credenziali Tuya.", - "title": "Tuya" + "title": "Integrazione Tuya" } } }, diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json index 56b2ae8236f..0ceb0c916cc 100644 --- a/homeassistant/components/tuya/translations/nl.json +++ b/homeassistant/components/tuya/translations/nl.json @@ -6,15 +6,33 @@ "single_instance_allowed": "Al geconfigureerd. Er is maar een configuratie mogelijk." }, "error": { - "invalid_auth": "Ongeldige authenticatie" + "invalid_auth": "Ongeldige authenticatie", + "login_error": "Aanmeldingsfout ({code}): {msg}" }, "flow_title": "Tuya-configuratie", "step": { + "login": { + "data": { + "access_id": "Toegangs-ID", + "access_secret": "Access Secret", + "country_code": "Landcode", + "endpoint": "Beschikbaarheidszone", + "password": "Wachtwoord", + "tuya_app_type": "Mobiele app", + "username": "Account" + }, + "description": "Voer uw Tuya-inloggegevens in", + "title": "Tuya" + }, "user": { "data": { + "access_id": "Tuya IoT-toegangs-ID", + "access_secret": "Tuya IoT Access Secret", "country_code": "De landcode van uw account (bijvoorbeeld 1 voor de VS of 86 voor China)", "password": "Wachtwoord", "platform": "De app waar uw account is geregistreerd", + "region": "Regio", + "tuya_project_type": "Tuya cloud project type", "username": "Gebruikersnaam" }, "description": "Voer uw Tuya-inloggegevens in.", diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json index eedf24be696..b5fe4bc1851 100644 --- a/homeassistant/components/tuya/translations/no.json +++ b/homeassistant/components/tuya/translations/no.json @@ -10,15 +10,29 @@ }, "flow_title": "Tuya konfigurasjon", "step": { + "login": { + "data": { + "access_id": "Tilgangs -ID", + "access_secret": "Tilgangshemmelighet", + "country_code": "Landskode", + "endpoint": "Tilgjengelighetssone", + "password": "Passord", + "tuya_app_type": "Mobilapp", + "username": "Konto" + }, + "description": "Skriv inn Tuya-legitimasjonen din", + "title": "Tuya" + }, "user": { "data": { "country_code": "Din landskode for kontoen din (f.eks. 1 for USA eller 86 for Kina)", "password": "Passord", "platform": "Appen der kontoen din er registrert", + "tuya_project_type": "Tuya -skyprosjekttype", "username": "Brukernavn" }, "description": "Angi Tuya-legitimasjonen din.", - "title": "" + "title": "Tuya Integrasjon" } } }, diff --git a/homeassistant/components/tuya/translations/ru.json b/homeassistant/components/tuya/translations/ru.json index 7b46689bc50..8e00eee568c 100644 --- a/homeassistant/components/tuya/translations/ru.json +++ b/homeassistant/components/tuya/translations/ru.json @@ -10,11 +10,25 @@ }, "flow_title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Tuya", "step": { + "login": { + "data": { + "access_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "access_secret": "\u0421\u0435\u043a\u0440\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b", + "endpoint": "\u0417\u043e\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0441\u0442\u0438", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "tuya_app_type": "\u041c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "username": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c" + }, + "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 Tuya.", + "title": "Tuya" + }, "user": { "data": { "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 (1 \u0434\u043b\u044f \u0421\u0428\u0410 \u0438\u043b\u0438 86 \u0434\u043b\u044f \u041a\u0438\u0442\u0430\u044f)", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "platform": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c", + "tuya_project_type": "\u0422\u0438\u043f \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Tuya", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, "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 Tuya.", diff --git a/homeassistant/components/tuya/translations/zh-Hant.json b/homeassistant/components/tuya/translations/zh-Hant.json index 7221c86eb63..e747e50d2c7 100644 --- a/homeassistant/components/tuya/translations/zh-Hant.json +++ b/homeassistant/components/tuya/translations/zh-Hant.json @@ -10,15 +10,29 @@ }, "flow_title": "Tuya \u8a2d\u5b9a", "step": { + "login": { + "data": { + "access_id": "Access ID", + "access_secret": "Access Secret", + "country_code": "\u570b\u78bc", + "endpoint": "\u53ef\u7528\u5340\u57df", + "password": "\u5bc6\u78bc", + "tuya_app_type": "\u624b\u6a5f App", + "username": "\u5e33\u865f" + }, + "description": "\u8f38\u5165 Tuya \u6191\u8b49", + "title": "Tuya" + }, "user": { "data": { "country_code": "\u5e33\u865f\u570b\u5bb6\u4ee3\u78bc\uff08\u4f8b\u5982\uff1a\u7f8e\u570b 1 \u6216\u4e2d\u570b 86\uff09", "password": "\u5bc6\u78bc", "platform": "\u5e33\u6236\u8a3b\u518a\u6240\u5728\u4f4d\u7f6e", + "tuya_project_type": "Tuya \u96f2\u5c08\u6848\u985e\u578b", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, "description": "\u8f38\u5165 Tuya \u6191\u8b49\u3002", - "title": "Tuya" + "title": "Tuya \u6574\u5408" } } }, diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 0069c3db93c..89c750ec865 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -12,7 +12,13 @@ from twentemilieu import ( from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME, CONF_ID +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + CONF_ID, + DEVICE_CLASS_DATE, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -74,6 +80,8 @@ async def async_setup_entry( class TwenteMilieuSensor(SensorEntity): """Defines a Twente Milieu sensor.""" + _attr_device_class = DEVICE_CLASS_DATE + def __init__( self, twentemilieu: TwenteMilieu, diff --git a/homeassistant/components/twilio/translations/hu.json b/homeassistant/components/twilio/translations/hu.json index cd60890dab3..512296463e4 100644 --- a/homeassistant/components/twilio/translations/hu.json +++ b/homeassistant/components/twilio/translations/hu.json @@ -2,14 +2,14 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", - "webhook_not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." + "webhook_not_internet_accessible": "A Home Assistant p\u00e9ld\u00e1ny\u00e1nak el\u00e9rhet\u0151nek kell lennie az internet fel\u0151l a webhook \u00fczenetek fogad\u00e1s\u00e1hoz." }, "create_entry": { - "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Webhooks Twilio-val]({twilio_url}) alkalmaz\u00e1st. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/x-www-form-urlencoded \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatiz\u00e1l\u00e1sokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." + "default": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a [Webhooks Twilio-val]({twilio_url}) alkalmaz\u00e1st. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: `{webhook_url}`\n - M\u00f3dszer: POST\n - Tartalom t\u00edpusa: application/x-www-form-urlencoded \n\n L\u00e1sd [a dokument\u00e1ci\u00f3]({docs_url}), hogyan konfigur\u00e1lhatja az automatizmusokat a bej\u00f6v\u0151 adatok kezel\u00e9s\u00e9re." }, "step": { "user": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "A Twilio Webhook be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/twilio/translations/nl.json b/homeassistant/components/twilio/translations/nl.json index 0d5d33a727e..3d31175d2de 100644 --- a/homeassistant/components/twilio/translations/nl.json +++ b/homeassistant/components/twilio/translations/nl.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Wil je beginnen met instellen?", + "description": "Wilt u beginnen met instellen?", "title": "Stel de Twilio Webhook in" } } diff --git a/homeassistant/components/twinkly/translations/fr.json b/homeassistant/components/twinkly/translations/fr.json index c26edea54ee..02ba8cb1b3e 100644 --- a/homeassistant/components/twinkly/translations/fr.json +++ b/homeassistant/components/twinkly/translations/fr.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "device_exists": "D\u00e9j\u00e0 configur\u00e9" + "device_exists": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Connexion impossible" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/twinkly/translations/hu.json b/homeassistant/components/twinkly/translations/hu.json index d5cd872bbd0..9c5137c30a4 100644 --- a/homeassistant/components/twinkly/translations/hu.json +++ b/homeassistant/components/twinkly/translations/hu.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "host": "A Twinkly eszk\u00f6z gazdag\u00e9pe (vagy IP-c\u00edme)" + "host": "A Twinkly eszk\u00f6z c\u00edme" }, "description": "\u00c1ll\u00edtsa be a Twinkly led-karakterl\u00e1nc\u00e1t", "title": "Twinkly" diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json index 077e72f5485..ffd42b8b0fe 100644 --- a/homeassistant/components/twitter/manifest.json +++ b/homeassistant/components/twitter/manifest.json @@ -2,7 +2,7 @@ "domain": "twitter", "name": "Twitter", "documentation": "https://www.home-assistant.io/integrations/twitter", - "requirements": ["TwitterAPI==2.7.3"], + "requirements": ["TwitterAPI==2.7.5"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 0877cda7475..2394dfe92d8 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,6 +11,7 @@ from .const import ( UNIFI_WIRELESS_CLIENTS, ) from .controller import UniFiController +from .services import async_setup_services, async_unload_services SAVE_DELAY = 10 STORAGE_KEY = "unifi_data" @@ -43,6 +44,7 @@ async def async_setup_entry(hass, config_entry): ) hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller + await async_setup_services(hass) config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) @@ -68,6 +70,10 @@ async def async_setup_entry(hass, config_entry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" controller = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + + if not hass.data[UNIFI_DOMAIN]: + await async_unload_services(hass) + return await controller.async_reset() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7f70d4c9f37..a32fc42715f 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,8 +3,12 @@ "name": "Ubiquiti UniFi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==26"], - "codeowners": ["@Kane610"], + "requirements": [ + "aiounifi==27" + ], + "codeowners": [ + "@Kane610" + ], "quality_scale": "platinum", "ssdp": [ { @@ -19,4 +23,4 @@ } ], "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 6a009415163..5cbf61d9635 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -157,7 +157,7 @@ class UniFiUpTimeSensor(UniFiClient, SensorEntity): self.last_updated_time = self.client.uptime if not update_state: - return None + return super().async_update_callback() diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py new file mode 100644 index 00000000000..dca95a764c3 --- /dev/null +++ b/homeassistant/components/unifi/services.py @@ -0,0 +1,69 @@ +"""UniFi services.""" + +from .const import DOMAIN as UNIFI_DOMAIN + +UNIFI_SERVICES = "unifi_services" + +SERVICE_REMOVE_CLIENTS = "remove_clients" + + +async def async_setup_services(hass) -> None: + """Set up services for UniFi integration.""" + if hass.data.get(UNIFI_SERVICES, False): + return + + hass.data[UNIFI_SERVICES] = True + + async def async_call_unifi_service(service_call) -> None: + """Call correct UniFi service.""" + service = service_call.service + service_data = service_call.data + + controllers = hass.data[UNIFI_DOMAIN].values() + + if service == SERVICE_REMOVE_CLIENTS: + await async_remove_clients(controllers, service_data) + + hass.services.async_register( + UNIFI_DOMAIN, + SERVICE_REMOVE_CLIENTS, + async_call_unifi_service, + ) + + +async def async_unload_services(hass) -> None: + """Unload UniFi services.""" + if not hass.data.get(UNIFI_SERVICES): + return + + hass.data[UNIFI_SERVICES] = False + + hass.services.async_remove(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS) + + +async def async_remove_clients(controllers, data) -> None: + """Remove select clients from controller. + + Validates based on: + - Total time between first seen and last seen is less than 15 minutes. + - Neither IP, hostname nor name is configured. + """ + for controller in controllers: + + if not controller.available: + continue + + clients_to_remove = [] + + for client in controller.api.clients_all.values(): + + if client.last_seen - client.first_seen > 900: + continue + + if any({client.fixed_ip, client.hostname, client.name}): + continue + + clients_to_remove.append(client.mac) + + if clients_to_remove: + await controller.api.clients.remove_clients(macs=clients_to_remove) diff --git a/homeassistant/components/unifi/services.yaml b/homeassistant/components/unifi/services.yaml new file mode 100644 index 00000000000..435661afd4a --- /dev/null +++ b/homeassistant/components/unifi/services.yaml @@ -0,0 +1,3 @@ +remove_clients: + name: Remove clients from the UniFi Controller + description: Clean up clients that has only been associated with the controller for a short period of time. diff --git a/homeassistant/components/unifi/translations/fr.json b/homeassistant/components/unifi/translations/fr.json index d750fb0cdd9..5486b633fd4 100644 --- a/homeassistant/components/unifi/translations/fr.json +++ b/homeassistant/components/unifi/translations/fr.json @@ -14,12 +14,12 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP", + "host": "H\u00f4te", "password": "Mot de passe", "port": "Port", "site": "ID du site", "username": "Nom d'utilisateur", - "verify_ssl": "Contr\u00f4leur utilisant un certificat appropri\u00e9" + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "title": "Configurer le contr\u00f4leur UniFi" } diff --git a/homeassistant/components/unifi/translations/he.json b/homeassistant/components/unifi/translations/he.json index 83c34cb9c77..848b1c5fbc5 100644 --- a/homeassistant/components/unifi/translations/he.json +++ b/homeassistant/components/unifi/translations/he.json @@ -29,9 +29,14 @@ }, "device_tracker": { "data": { + "detection_time": "\u05d4\u05d6\u05de\u05df \u05d1\u05e9\u05e0\u05d9\u05d5\u05ea \u05de\u05d4\u05e4\u05e2\u05dd \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d4 \u05e9\u05e0\u05e8\u05d0\u05d4 \u05e2\u05d3 \u05e9\u05e0\u05d7\u05e9\u05d1 \u05d1\u05d7\u05d5\u05e5", + "ignore_wired_bug": "\u05d4\u05e9\u05d1\u05ea \u05d0\u05ea \u05dc\u05d5\u05d2\u05d9\u05e7\u05ea \u05d1\u05d0\u05d2\u05d9\u05dd \u05e7\u05d5\u05d5\u05d9\u05ea UniFi", + "ssid_filter": "\u05d1\u05d7\u05d9\u05e8\u05ea SSID \u05dc\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05d0\u05dc\u05d7\u05d5\u05d8\u05d9\u05d9\u05dd \u05d1-", "track_clients": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05e8\u05e9\u05ea", - "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)" - } + "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)", + "track_wired_clients": "\u05d4\u05db\u05dc\u05dc\u05ea \u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05e8\u05e9\u05ea \u05e7\u05d5\u05d5\u05d9\u05ea" + }, + "description": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" }, "simple_options": { "data": { diff --git a/homeassistant/components/unifi/translations/hu.json b/homeassistant/components/unifi/translations/hu.json index 22904c8ec7b..9564b211043 100644 --- a/homeassistant/components/unifi/translations/hu.json +++ b/homeassistant/components/unifi/translations/hu.json @@ -14,10 +14,10 @@ "step": { "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "password": "Jelsz\u00f3", "port": "Port", - "site": "Site azonos\u00edt\u00f3", + "site": "Hely azonos\u00edt\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, @@ -31,10 +31,10 @@ "data": { "block_client": "H\u00e1l\u00f3zathozz\u00e1f\u00e9r\u00e9s vez\u00e9relt \u00fcgyfelek", "dpi_restrictions": "Enged\u00e9lyezze a DPI restrikci\u00f3s csoportok vez\u00e9rl\u00e9s\u00e9t", - "poe_clients": "Enged\u00e9lyezze az \u00fcgyfelek POE-vez\u00e9rl\u00e9s\u00e9t" + "poe_clients": "Enged\u00e9lyezze a POE-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.", - "title": "UniFi lehet\u0151s\u00e9gek 2/3" + "title": "UniFi be\u00e1ll\u00edt\u00e1sok 2/3" }, "device_tracker": { "data": { @@ -46,7 +46,7 @@ "track_wired_clients": "Vegyen fel vezet\u00e9kes h\u00e1l\u00f3zati \u00fcgyfeleket" }, "description": "Eszk\u00f6zk\u00f6vet\u00e9s konfigur\u00e1l\u00e1sa", - "title": "UniFi lehet\u0151s\u00e9gek 1/3" + "title": "UniFi be\u00e1ll\u00edt\u00e1sok 1/3" }, "init": { "data": { @@ -64,11 +64,11 @@ }, "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": "Kliensenk\u00e9nti s\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa", "allow_uptime_sensors": "\u00dczemid\u0151-\u00e9rz\u00e9kel\u0151k h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra" }, "description": "Statisztikai \u00e9rz\u00e9kel\u0151k konfigur\u00e1l\u00e1sa", - "title": "UniFi lehet\u0151s\u00e9gek 3/3" + "title": "UniFi be\u00e1ll\u00edt\u00e1sok 3/3" } } } diff --git a/homeassistant/components/unifi/translations/id.json b/homeassistant/components/unifi/translations/id.json index 7a707b28aa0..ec023fa7363 100644 --- a/homeassistant/components/unifi/translations/id.json +++ b/homeassistant/components/unifi/translations/id.json @@ -10,7 +10,7 @@ "service_unavailable": "Gagal terhubung", "unknown_client_mac": "Tidak ada klien yang tersedia di alamat MAC tersebut" }, - "flow_title": "UniFi Network {site} ({host})", + "flow_title": "{site} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/unifi/translations/ru.json b/homeassistant/components/unifi/translations/ru.json index 8a34f9fc17e..a2c9bfe8061 100644 --- a/homeassistant/components/unifi/translations/ru.json +++ b/homeassistant/components/unifi/translations/ru.json @@ -38,7 +38,7 @@ }, "device_tracker": { "data": { - "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", "ignore_wired_bug": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043b\u043e\u0433\u0438\u043a\u0443 \u043e\u0448\u0438\u0431\u043a\u0438 \u0434\u043b\u044f \u043d\u0435 \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 UniFi", "ssid_filter": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 SSID \u0434\u043b\u044f \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432", "track_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438", diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py index 03c63ce4e84..9d2d8071fca 100644 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ b/homeassistant/components/unifi/unifi_entity_base.py @@ -83,6 +83,7 @@ class UniFiBase(Entity): Remove entity if no entry in entity registry exist. Remove entity registry entry if no entry in device registry exist. Remove device registry entry if there is only one linked entity (this entity). + Remove config entry reference from device registry entry if there is more than one config entry. Remove entity registry entry if there are more than one entity linked to the device registry entry. """ if self.key not in keys: @@ -102,17 +103,33 @@ class UniFiBase(Entity): if ( len( - async_entries_for_device( + entries_for_device := async_entries_for_device( entity_registry, entity_entry.device_id, include_disabled_entities=True, ) ) - == 1 - ): + ) == 1: device_registry.async_remove_device(device_entry.id) return + if ( + len( + entries_for_device_from_this_config_entry := [ + entry_for_device + for entry_for_device in entries_for_device + if entry_for_device.config_entry_id + == self.controller.config_entry.entry_id + ] + ) + != len(entries_for_device) + and len(entries_for_device_from_this_config_entry) == 1 + ): + device_registry.async_update_device( + entity_entry.device_id, + remove_config_entry_id=self.controller.config_entry.entry_id, + ) + entity_registry.async_remove(self.entity_id) @property diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 2e3e6892c1c..d658a44a117 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -87,6 +87,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import EVENT_HOMEASSISTANT_START, callback from homeassistant.exceptions import TemplateError @@ -101,7 +102,7 @@ CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" -OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE] +OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN] ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string) CMD_SCHEMA = cv.schema_with_slug_keys(cv.SERVICE_SCHEMA) diff --git a/homeassistant/components/upb/translations/fr.json b/homeassistant/components/upb/translations/fr.json index f4d3279c284..6f96f42f3dd 100644 --- a/homeassistant/components/upb/translations/fr.json +++ b/homeassistant/components/upb/translations/fr.json @@ -4,9 +4,9 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Impossible de se connecter \u00e0 UPB PIM, veuillez r\u00e9essayer.", + "cannot_connect": "\u00c9chec de connexion", "invalid_upb_file": "Fichier d'exportation UPB UPStart manquant ou invalide, v\u00e9rifiez le nom et le chemin du fichier.", - "unknown": "Erreur inattendue." + "unknown": "Erreur inattendue" }, "step": { "user": { diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py index 25339f6308a..10090946f6e 100644 --- a/homeassistant/components/updater/binary_sensor.py +++ b/homeassistant/components/updater/binary_sensor.py @@ -26,11 +26,14 @@ class UpdaterBinary(CoordinatorEntity, BinarySensorEntity): _attr_unique_id = "updater" @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - if not self.coordinator.data: - return None - return self.coordinator.data.update_available + def available(self) -> bool: + """Return if entity is available.""" + return True + + @property + def is_on(self) -> bool: + """Return true if there is an update available.""" + return self.coordinator.data and self.coordinator.data.update_available @property def extra_state_attributes(self) -> dict | None: diff --git a/homeassistant/components/updater/translations/hu.json b/homeassistant/components/updater/translations/hu.json index 52b2c972559..e862dcb360c 100644 --- a/homeassistant/components/updater/translations/hu.json +++ b/homeassistant/components/updater/translations/hu.json @@ -1,3 +1,3 @@ { - "title": "Friss\u00edt\u00e9sek" + "title": "Friss\u00edt\u0151" } \ No newline at end of file diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 80a7753ec8c..6db8b087378 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -3,20 +3,24 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from dataclasses import dataclass from datetime import timedelta from ipaddress import ip_address from typing import Any +from async_upnp_client.exceptions import UpnpConnectionError 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.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.ssdp import SsdpChange from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +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 import device_registry as dr +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -31,9 +35,7 @@ from .const import ( CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, DOMAIN, - DOMAIN_CONFIG, DOMAIN_DEVICES, - DOMAIN_LOCAL_IP, LOGGER, ) from .device import Device @@ -44,27 +46,27 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), - }, - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + vol.All( + cv.deprecated(CONF_LOCAL_IP), + { + vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), + }, + ) + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up UPnP component.""" - LOGGER.debug("async_setup, config: %s", config) - conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - conf = config.get(DOMAIN, conf_default) - local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) hass.data[DOMAIN] = { - DOMAIN_CONFIG: conf, DOMAIN_DEVICES: {}, - DOMAIN_LOCAL_IP: conf.get(CONF_LOCAL_IP, local_ip), } # Only start if set up via configuration.yaml. @@ -90,16 +92,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_discovered_event = asyncio.Event() discovery_info: Mapping[str, Any] | None = None - @callback - def device_discovered(info: Mapping[str, Any]) -> None: + async def device_discovered(headers: Mapping[str, Any], change: SsdpChange) -> None: + if change == SsdpChange.BYEBYE: + return + nonlocal discovery_info LOGGER.debug( - "Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION] + "Device discovered: %s, at: %s", usn, headers[ssdp.ATTR_SSDP_LOCATION] ) - discovery_info = info + discovery_info = headers device_discovered_event.set() - cancel_discovered_callback = ssdp.async_register_callback( + cancel_discovered_callback = await ssdp.async_register_callback( hass, device_discovered, { @@ -119,7 +123,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: location = discovery_info[ # pylint: disable=unsubscriptable-object ssdp.ATTR_SSDP_LOCATION ] - device = await Device.async_create_device(hass, location) + try: + device = await Device.async_create_device(hass, location) + except UpnpConnectionError as err: + LOGGER.debug("Error connecting to device %s", location) + raise ConfigEntryNotReady from err # Ensure entry has a unique_id. if not entry.unique_id: @@ -174,9 +182,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Enabling sensors") hass.config_entries.async_setup_platforms(entry, PLATFORMS) - # Start device updater. - await device.async_start() - return True @@ -184,13 +189,24 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) - if coordinator := hass.data[DOMAIN].pop(config_entry.entry_id, None): - await coordinator.device.async_stop() - LOGGER.debug("Deleting sensors") return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +@dataclass +class UpnpBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes UPnP entities.""" + + format: str = "s" + + +@dataclass +class UpnpSensorEntityDescription(SensorEntityDescription): + """A class that describes a sensor UPnP entities.""" + + format: str = "s" + + class UpnpDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to update data from UPNP device.""" @@ -211,24 +227,40 @@ class UpnpDataUpdateCoordinator(DataUpdateCoordinator): self.device.async_get_status(), ) - data = dict(update_values[0]) - data.update(update_values[1]) - - return data + return { + **update_values[0], + **update_values[1], + } class UpnpEntity(CoordinatorEntity): """Base class for UPnP/IGD entities.""" coordinator: UpnpDataUpdateCoordinator + entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription - def __init__(self, coordinator: UpnpDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpSensorEntityDescription + | UpnpBinarySensorEntityDescription, + ) -> None: """Initialize the base entities.""" super().__init__(coordinator) self._device = coordinator.device + self.entity_description = entity_description + self._attr_name = f"{coordinator.device.name} {entity_description.name}" + self._attr_unique_id = f"{coordinator.device.udn}_{entity_description.key}" self._attr_device_info = { "connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)}, "name": coordinator.device.name, "manufacturer": coordinator.device.manufacturer, "model": coordinator.device.model_name, } + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and ( + self.coordinator.data.get(self.entity_description.key) or False + ) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 2f2f0af0e96..3bf9635c78b 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -9,8 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator, UpnpEntity -from .const import DOMAIN, LOGGER, WANSTATUS +from . import UpnpBinarySensorEntityDescription, UpnpDataUpdateCoordinator, UpnpEntity +from .const import DOMAIN, LOGGER, WAN_STATUS + +BINARYSENSOR_ENTITY_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( + UpnpBinarySensorEntityDescription( + key=WAN_STATUS, + name="wan status", + ), +) async def async_setup_entry( @@ -23,10 +30,14 @@ async def async_setup_entry( LOGGER.debug("Adding binary sensor") - sensors = [ - UpnpStatusBinarySensor(coordinator), - ] - async_add_entities(sensors) + async_add_entities( + UpnpStatusBinarySensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in BINARYSENSOR_ENTITY_DESCRIPTIONS + if coordinator.data.get(entity_description.key) is not None + ) class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): @@ -37,18 +48,12 @@ class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): def __init__( self, coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpBinarySensorEntityDescription, ) -> None: """Initialize the base sensor.""" - super().__init__(coordinator) - self._attr_name = f"{coordinator.device.name} wan status" - self._attr_unique_id = f"{coordinator.device.udn}_wanstatus" - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.coordinator.data.get(WANSTATUS) + super().__init__(coordinator=coordinator, entity_description=entity_description) @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.coordinator.data[WANSTATUS] == "Connected" + return self.coordinator.data[self.entity_description.key] == "Connected" diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 5df4e267427..d1c2c4b3c0f 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.ssdp import SsdpChange from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, callback @@ -40,8 +41,10 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: """Wait for a device to be discovered.""" device_discovered_event = asyncio.Event() - @callback - def device_discovered(info: Mapping[str, Any]) -> None: + async def device_discovered(info: Mapping[str, Any], change: SsdpChange) -> None: + if change == SsdpChange.BYEBYE: + return + LOGGER.info( "Device discovered: %s, at: %s", info[ssdp.ATTR_SSDP_USN], @@ -49,14 +52,14 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: ) device_discovered_event.set() - cancel_discovered_callback_1 = ssdp.async_register_callback( + cancel_discovered_callback_1 = await ssdp.async_register_callback( hass, device_discovered, { ssdp.ATTR_SSDP_ST: ST_IGD_V1, }, ) - cancel_discovered_callback_2 = ssdp.async_register_callback( + cancel_discovered_callback_2 = await ssdp.async_register_callback( hass, device_discovered, { @@ -77,11 +80,11 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: return True -def _discovery_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: +async def _async_discover_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: """Discovery IGD devices.""" - return ssdp.async_get_discovery_info_by_st( + return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 - ) + ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2) + ) + await ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2) class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -121,7 +124,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry_from_discovery(discovery) # Discover devices. - discoveries = _discovery_igd_devices(self.hass) + discoveries = await _async_discover_igd_devices(self.hass) # Store discoveries which have not been configured. current_unique_ids = { @@ -171,7 +174,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Discover devices. await _async_wait_for_discoveries(self.hass) - discoveries = _discovery_igd_devices(self.hass) + discoveries = await _async_discover_igd_devices(self.hass) # Ensure anything to add. If not, silently abort. if not discoveries: @@ -270,7 +273,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): title = _friendly_name_from_discovery(discovery) data = { - CONFIG_ENTRY_UDN: discovery["_udn"], + CONFIG_ENTRY_UDN: discovery[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_ST: discovery[ssdp.ATTR_SSDP_ST], CONFIG_ENTRY_HOSTNAME: discovery["_host"], } diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 769e398c5a4..71c72acd594 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -8,9 +8,7 @@ LOGGER = logging.getLogger(__package__) CONF_LOCAL_IP = "local_ip" DOMAIN = "upnp" -DOMAIN_CONFIG = "config" DOMAIN_DEVICES = "devices" -DOMAIN_LOCAL_IP = "local_ip" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" @@ -18,9 +16,9 @@ PACKETS_SENT = "packets_sent" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" -WANSTATUS = "wan_status" -WANIP = "wan_ip" -UPTIME = "uptime" +WAN_STATUS = "wan_status" +ROUTER_IP = "ip" +ROUTER_UPTIME = "uptime" KIBIBYTE = 1024 UPDATE_INTERVAL = timedelta(seconds=30) CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index ca06f501405..1a6f50004cd 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -3,15 +3,16 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from ipaddress import IPv4Address from typing import Any from urllib.parse import urlparse -from async_upnp_client import UpnpFactory +from async_upnp_client import UpnpDevice, UpnpFactory from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.device_updater import DeviceUpdater +from async_upnp_client.exceptions import UpnpError from async_upnp_client.profiles.igd import IgdDevice +from homeassistant.components import ssdp +from homeassistant.components.ssdp import SsdpChange from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -20,68 +21,71 @@ import homeassistant.util.dt as dt_util from .const import ( BYTES_RECEIVED, BYTES_SENT, - CONF_LOCAL_IP, - DOMAIN, - DOMAIN_CONFIG, LOGGER as _LOGGER, PACKETS_RECEIVED, PACKETS_SENT, + ROUTER_IP, + ROUTER_UPTIME, TIMESTAMP, - UPTIME, - WANIP, - WANSTATUS, + WAN_STATUS, ) -def _get_local_ip(hass: HomeAssistant) -> IPv4Address | None: - """Get the configured local ip.""" - if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]: - local_ip = hass.data[DOMAIN][DOMAIN_CONFIG].get(CONF_LOCAL_IP) - if local_ip: - return IPv4Address(local_ip) - return None - - class Device: """Home Assistant representation of a UPnP/IGD device.""" - def __init__(self, igd_device: IgdDevice, device_updater: DeviceUpdater) -> None: + def __init__(self, hass: HomeAssistant, igd_device: IgdDevice) -> None: """Initialize UPnP/IGD device.""" + self.hass = hass self._igd_device = igd_device - self._device_updater = device_updater self.coordinator: DataUpdateCoordinator = None + @classmethod + async def async_create_upnp_device( + cls, hass: HomeAssistant, ssdp_location: str + ) -> UpnpDevice: + """Create UPnP device.""" + # Build async_upnp_client requester. + session = async_get_clientsession(hass) + requester = AiohttpSessionRequester(session, True, 20) + + # Create async_upnp_client device. + factory = UpnpFactory(requester, disable_state_variable_validation=True) + return await factory.async_create_device(ssdp_location) + @classmethod async def async_create_device( cls, hass: HomeAssistant, ssdp_location: str ) -> Device: """Create UPnP/IGD device.""" - # Build async_upnp_client requester. - session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True, 10) - - # Create async_upnp_client device. - factory = UpnpFactory(requester, disable_state_variable_validation=True) - upnp_device = await factory.async_create_device(ssdp_location) + upnp_device = await Device.async_create_upnp_device(hass, ssdp_location) # Create profile wrapper. igd_device = IgdDevice(upnp_device, None) + device = cls(hass, igd_device) - # Create updater. - local_ip = _get_local_ip(hass) - device_updater = DeviceUpdater( - device=upnp_device, factory=factory, source_ip=local_ip + # Register SSDP callback for updates. + usn = f"{upnp_device.udn}::{upnp_device.device_type}" + await ssdp.async_register_callback( + hass, device.async_ssdp_callback, {ssdp.ATTR_SSDP_USN: usn} ) - return cls(igd_device, device_updater) + return device - async def async_start(self) -> None: - """Start the device updater.""" - await self._device_updater.async_start() + async def async_ssdp_callback( + self, headers: Mapping[str, Any], change: SsdpChange + ) -> None: + """SSDP callback, update if needed.""" + if change != SsdpChange.UPDATE or ssdp.ATTR_SSDP_LOCATION not in headers: + return - async def async_stop(self) -> None: - """Stop the device updater.""" - await self._device_updater.async_stop() + location = headers[ssdp.ATTR_SSDP_LOCATION] + device = self._igd_device.device + if location == device.device_url: + return + + new_upnp_device = Device.async_create_upnp_device(self.hass, location) + device.reinit(new_upnp_device) @property def udn(self) -> str: @@ -165,10 +169,29 @@ class Device: values = await asyncio.gather( self._igd_device.async_get_status_info(), self._igd_device.async_get_external_ip_address(), + return_exceptions=True, ) + result = [] + for idx, value in enumerate(values): + if isinstance(value, UpnpError): + # Not all routers support some of these items although based + # on defined standard they should. + _LOGGER.debug( + "Exception occurred while trying to get status %s for device %s: %s", + "status" if idx == 1 else "external IP address", + self, + str(value), + ) + result.append(None) + continue + + if isinstance(value, Exception): + raise value + + result.append(value) return { - WANSTATUS: values[0][0] if values[0] is not None else None, - UPTIME: values[0][2] if values[0] is not None else None, - WANIP: values[1], + WAN_STATUS: result[0][0] if result[0] is not None else None, + ROUTER_UPTIME: result[0][2] if result[0] is not None else None, + ROUTER_IP: result[1], } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 5f38a827ec7..9a1875777a6 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.20.0"], + "requirements": ["async-upnp-client==0.22.5"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 185d3ecac6d..8ad8677b647 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -3,11 +3,11 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND +from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator, UpnpEntity +from . import UpnpDataUpdateCoordinator, UpnpEntity, UpnpSensorEntityDescription from .const import ( BYTES_RECEIVED, BYTES_SENT, @@ -15,59 +15,93 @@ from .const import ( DATA_RATE_PACKETS_PER_SECOND, DOMAIN, KIBIBYTE, - LOGGER, PACKETS_RECEIVED, PACKETS_SENT, + ROUTER_IP, + ROUTER_UPTIME, TIMESTAMP, + WAN_STATUS, ) -SENSOR_TYPES = { - BYTES_RECEIVED: { - "device_value_key": BYTES_RECEIVED, - "name": f"{DATA_BYTES} received", - "unit": DATA_BYTES, - "unique_id": BYTES_RECEIVED, - "derived_name": f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", - "derived_unit": DATA_RATE_KIBIBYTES_PER_SECOND, - "derived_unique_id": "KiB/sec_received", - }, - BYTES_SENT: { - "device_value_key": BYTES_SENT, - "name": f"{DATA_BYTES} sent", - "unit": DATA_BYTES, - "unique_id": BYTES_SENT, - "derived_name": f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", - "derived_unit": DATA_RATE_KIBIBYTES_PER_SECOND, - "derived_unique_id": "KiB/sec_sent", - }, - PACKETS_RECEIVED: { - "device_value_key": PACKETS_RECEIVED, - "name": f"{DATA_PACKETS} received", - "unit": DATA_PACKETS, - "unique_id": PACKETS_RECEIVED, - "derived_name": f"{DATA_RATE_PACKETS_PER_SECOND} received", - "derived_unit": DATA_RATE_PACKETS_PER_SECOND, - "derived_unique_id": "packets/sec_received", - }, - PACKETS_SENT: { - "device_value_key": PACKETS_SENT, - "name": f"{DATA_PACKETS} sent", - "unit": DATA_PACKETS, - "unique_id": PACKETS_SENT, - "derived_name": f"{DATA_RATE_PACKETS_PER_SECOND} sent", - "derived_unit": DATA_RATE_PACKETS_PER_SECOND, - "derived_unique_id": "packets/sec_sent", - }, -} +RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( + UpnpSensorEntityDescription( + key=BYTES_RECEIVED, + name=f"{DATA_BYTES} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_BYTES, + format="d", + ), + UpnpSensorEntityDescription( + key=BYTES_SENT, + name=f"{DATA_BYTES} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_BYTES, + format="d", + ), + UpnpSensorEntityDescription( + key=PACKETS_RECEIVED, + name=f"{DATA_PACKETS} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_PACKETS, + format="d", + ), + UpnpSensorEntityDescription( + key=PACKETS_SENT, + name=f"{DATA_PACKETS} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_PACKETS, + format="d", + ), + UpnpSensorEntityDescription( + key=ROUTER_IP, + name="External IP", + icon="mdi:server-network", + ), + UpnpSensorEntityDescription( + key=ROUTER_UPTIME, + name="Uptime", + icon="mdi:server-network", + native_unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + format="d", + ), + UpnpSensorEntityDescription( + key=WAN_STATUS, + name="wan status", + icon="mdi:server-network", + ), +) - -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -) -> None: - """Old way of setting up UPnP/IGD sensors.""" - LOGGER.debug( - "async_setup_platform: config: %s, discovery: %s", config, discovery_info - ) +DERIVED_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( + UpnpSensorEntityDescription( + key="KiB/sec_received", + name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="KiB/sent", + name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="packets/sec_received", + name=f"{DATA_RATE_PACKETS_PER_SECOND} received", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + format=".1f", + ), + UpnpSensorEntityDescription( + key="packets/sent", + name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", + icon="mdi:server-network", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + format=".1f", + ), +) async def async_setup_entry( @@ -78,52 +112,31 @@ async def async_setup_entry( """Set up the UPnP/IGD sensors.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - LOGGER.debug("Adding sensors") - - sensors = [ - RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), - RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), - RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), - RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), - DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), + entities: list[UpnpSensor] = [ + RawUpnpSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in RAW_SENSORS + if coordinator.data.get(entity_description.key) is not None ] - async_add_entities(sensors) + entities.extend( + [ + DerivedUpnpSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in DERIVED_SENSORS + if coordinator.data.get(entity_description.key) is not None + ] + ) + + async_add_entities(entities) class UpnpSensor(UpnpEntity, SensorEntity): """Base class for UPnP/IGD sensors.""" - def __init__( - self, - coordinator: UpnpDataUpdateCoordinator, - sensor_type: dict[str, str], - ) -> None: - """Initialize the base sensor.""" - super().__init__(coordinator) - self._sensor_type = sensor_type - self._attr_name = f"{coordinator.device.name} {sensor_type['name']}" - self._attr_unique_id = f"{coordinator.device.udn}_{sensor_type['unique_id']}" - - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return "mdi:server-network" - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.coordinator.data.get( - self._sensor_type["device_value_key"] - ) - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._sensor_type["unit"] - class RawUpnpSensor(UpnpSensor): """Representation of a UPnP/IGD sensor.""" @@ -131,30 +144,26 @@ class RawUpnpSensor(UpnpSensor): @property def native_value(self) -> str | None: """Return the state of the device.""" - device_value_key = self._sensor_type["device_value_key"] - value = self.coordinator.data[device_value_key] + value = self.coordinator.data[self.entity_description.key] if value is None: return None - return format(value, "d") + return format(value, self.entity_description.format) class DerivedUpnpSensor(UpnpSensor): """Representation of a UNIT Sent/Received per second sensor.""" - def __init__(self, coordinator: UpnpDataUpdateCoordinator, sensor_type) -> None: + entity_description: UpnpSensorEntityDescription + + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpSensorEntityDescription, + ) -> None: """Initialize sensor.""" - super().__init__(coordinator, sensor_type) + super().__init__(coordinator=coordinator, entity_description=entity_description) self._last_value = None self._last_timestamp = None - self._attr_name = f"{coordinator.device.name} {sensor_type['derived_name']}" - self._attr_unique_id = ( - f"{coordinator.device.udn}_{sensor_type['derived_unique_id']}" - ) - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._sensor_type["derived_unit"] def _has_overflowed(self, current_value) -> bool: """Check if value has overflowed.""" @@ -164,8 +173,7 @@ class DerivedUpnpSensor(UpnpSensor): def native_value(self) -> str | None: """Return the state of the device.""" # Can't calculate any derivative if we have only one value. - device_value_key = self._sensor_type["device_value_key"] - current_value = self.coordinator.data[device_value_key] + current_value = self.coordinator.data[self.entity_description.key] if current_value is None: return None current_timestamp = self.coordinator.data[TIMESTAMP] @@ -176,7 +184,7 @@ class DerivedUpnpSensor(UpnpSensor): # Calculate derivative. delta_value = current_value - self._last_value - if self._sensor_type["unit"] == DATA_BYTES: + if self.entity_description.native_unit_of_measurement == DATA_BYTES: delta_value /= KIBIBYTE delta_time = current_timestamp - self._last_timestamp if delta_time.total_seconds() == 0: @@ -188,4 +196,4 @@ class DerivedUpnpSensor(UpnpSensor): self._last_value = current_value self._last_timestamp = current_timestamp - return format(derived, ".1f") + return format(derived, self.entity_description.format) diff --git a/homeassistant/components/upnp/translations/fi.json b/homeassistant/components/upnp/translations/fi.json index dcd927ffd24..aaf44e6c730 100644 --- a/homeassistant/components/upnp/translations/fi.json +++ b/homeassistant/components/upnp/translations/fi.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Laite on jo m\u00e4\u00e4ritetty" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/upnp/translations/fr.json b/homeassistant/components/upnp/translations/fr.json index ffbef69abe7..574cb9f4f93 100644 --- a/homeassistant/components/upnp/translations/fr.json +++ b/homeassistant/components/upnp/translations/fr.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "UPnP / IGD est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "incomplete_discovery": "D\u00e9couverte incompl\u00e8te", - "no_devices_found": "Aucun p\u00e9riph\u00e9rique UPnP / IGD trouv\u00e9 sur le r\u00e9seau." + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" }, "error": { "one": "Vide", diff --git a/homeassistant/components/upnp/translations/hu.json b/homeassistant/components/upnp/translations/hu.json index 8ef3ff8dcc0..46c6bd2de1f 100644 --- a/homeassistant/components/upnp/translations/hu.json +++ b/homeassistant/components/upnp/translations/hu.json @@ -13,7 +13,7 @@ "step": { "init": { "one": "\u00dcres", - "other": "" + "other": "\u00dcres" }, "ssdp_confirm": { "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt az UPnP/IGD eszk\u00f6zt?" diff --git a/homeassistant/components/upnp/translations/id.json b/homeassistant/components/upnp/translations/id.json index 3a953ba62a9..f70fca145e8 100644 --- a/homeassistant/components/upnp/translations/id.json +++ b/homeassistant/components/upnp/translations/id.json @@ -5,7 +5,7 @@ "incomplete_discovery": "Proses penemuan tidak selesai", "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan" }, - "flow_title": "UPnP/IGD: {name}", + "flow_title": "{name}", "step": { "ssdp_confirm": { "description": "Ingin menyiapkan perangkat UPnP/IGD ini?" diff --git a/homeassistant/components/uptimerobot/translations/ca.json b/homeassistant/components/uptimerobot/translations/ca.json index a3bccb98295..b845e271666 100644 --- a/homeassistant/components/uptimerobot/translations/ca.json +++ b/homeassistant/components/uptimerobot/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_failed_existing": "No s'ha pogut actualitzar l'entrada de configuraci\u00f3, elimina la integraci\u00f3 i torna-la a instal\u00b7lar.", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" diff --git a/homeassistant/components/uptimerobot/translations/el.json b/homeassistant/components/uptimerobot/translations/el.json new file mode 100644 index 00000000000..b9f2b180b4b --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/el.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "description": "\u03a0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03b1\u03c1\u03ad\u03c7\u03b5\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ba\u03bb\u03b5\u03b9\u03b4\u03af API \u03bc\u03cc\u03bd\u03bf \u03b3\u03b9\u03b1 \u03b1\u03bd\u03ac\u03b3\u03bd\u03c9\u03c3\u03b7 \u03b1\u03c0\u03cc \u03c4\u03bf {intergration}." + }, + "user": { + "description": "\u03a0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03b1\u03c1\u03ad\u03c7\u03b5\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ba\u03bb\u03b5\u03b9\u03b4\u03af API \u03bc\u03cc\u03bd\u03bf \u03b3\u03b9\u03b1 \u03b1\u03bd\u03ac\u03b3\u03bd\u03c9\u03c3\u03b7 \u03b1\u03c0\u03cc \u03c4\u03bf {intergration}." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/es.json b/homeassistant/components/uptimerobot/translations/es.json index d3c7f2b036d..455c96cd644 100644 --- a/homeassistant/components/uptimerobot/translations/es.json +++ b/homeassistant/components/uptimerobot/translations/es.json @@ -1,30 +1,30 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada", + "already_configured": "La cuenta ya ha sido configurada", "reauth_failed_existing": "No se pudo actualizar la entrada de configuraci\u00f3n, elimine la integraci\u00f3n y config\u00farela nuevamente.", - "reauth_successful": "La reautenticaci\u00f3n fue exitosa", - "unknown": "Error desconocido" + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", - "invalid_api_key": "Clave de la API err\u00f3nea", - "reauth_failed_matching_account": "La clave de API que proporcion\u00f3 no coincide con el ID de cuenta para la configuraci\u00f3n existente.", - "unknown": "Error desconocido" + "invalid_api_key": "Clave API no v\u00e1lida", + "reauth_failed_matching_account": "La clave de API que has proporcionado no coincide con el ID de cuenta para la configuraci\u00f3n existente.", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { "data": { "api_key": "API Key" }, - "description": "Debe proporcionar una nueva clave API de solo lectura de Uptime Robot", + "description": "Debes proporcionar una nueva clave API de solo lectura de Uptime Robot", "title": "Volver a autenticar la integraci\u00f3n" }, "user": { "data": { - "api_key": "Clave de la API" + "api_key": "Clave API" }, - "description": "Debe proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" + "description": "Debes proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" } } } diff --git a/homeassistant/components/uptimerobot/translations/fr.json b/homeassistant/components/uptimerobot/translations/fr.json index 2b4322bb410..6d20816632f 100644 --- a/homeassistant/components/uptimerobot/translations/fr.json +++ b/homeassistant/components/uptimerobot/translations/fr.json @@ -1,26 +1,28 @@ { "config": { "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "reauth_failed_existing": "Impossible de mettre \u00e0 jour l'entr\u00e9e de configuration, veuillez supprimer l'int\u00e9gration et la configurer \u00e0 nouveau.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { - "cannot_connect": "Echec de la connexion", - "invalid_api_key": "Cl\u00e9 API non valide", + "cannot_connect": "\u00c9chec de connexion", + "invalid_api_key": "Cl\u00e9 API invalide", "reauth_failed_matching_account": "La cl\u00e9 API que vous avez fournie ne correspond pas \u00e0 l\u2019ID de compte pour la configuration existante.", "unknown": "Erreur inattendue" }, "step": { "reauth_confirm": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, - "description": "Vous devez fournir une nouvelle cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" + "description": "Vous devez fournir une nouvelle cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot", + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { "data": { - "api_key": "Cl\u00e9 API" + "api_key": "Cl\u00e9 d'API" }, "description": "Vous devez fournir une cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" } diff --git a/homeassistant/components/uptimerobot/translations/id.json b/homeassistant/components/uptimerobot/translations/id.json new file mode 100644 index 00000000000..e107b1fcac6 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_api_key": "Kunci API tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API" + }, + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "api_key": "Kunci API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 13f18216cca..095d72f3ed4 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -161,6 +161,7 @@ class USBDiscovery: if device_tuple in self.seen: return self.seen.add(device_tuple) + matched = [] for matcher in self.usb: if "vid" in matcher and device.vid != matcher["vid"]: continue @@ -178,6 +179,20 @@ class USBDiscovery: device.description, matcher["description"] ): continue + matched.append(matcher) + + if not matched: + return + + sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) + most_matched_fields = len(sorted_by_most_targeted[0]) + + for matcher in sorted_by_most_targeted: + # If there is a less targeted match, we only + # want the most targeted match + if len(matcher) < most_matched_fields: + break + flow: USBFlow = { "domain": matcher["domain"], "context": {"source": config_entries.SOURCE_USB}, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 32ed90a9111..d64b40ed60b 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -24,6 +24,7 @@ from .const import ( CONF_TARIFF, CONF_TARIFF_ENTITY, CONF_TARIFFS, + DATA_TARIFF_SENSORS, DATA_UTILITY, DOMAIN, METER_TYPES, @@ -98,6 +99,7 @@ async def async_setup(hass, config): _LOGGER.debug("Setup %s.%s", DOMAIN, meter) hass.data[DATA_UTILITY][meter] = conf + hass.data[DATA_UTILITY][meter][DATA_TARIFF_SENSORS] = [] if not conf[CONF_TARIFFS]: # only one entity is required diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 3be6fa9a061..3e127e4a643 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -22,6 +22,7 @@ METER_TYPES = [ ] DATA_UTILITY = "utility_meter_data" +DATA_TARIFF_SENSORS = "utility_meter_sensors" CONF_METER = "meter" CONF_SOURCE_SENSOR = "source" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ee3fed02a6b..50185461030 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,5 +1,6 @@ """Utility meter from sensors providing raw data.""" from datetime import date, datetime, timedelta +import decimal from decimal import Decimal, DecimalException import logging @@ -8,7 +9,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( ATTR_LAST_RESET, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -46,6 +47,7 @@ from .const import ( CONF_TARIFF, CONF_TARIFF_ENTITY, DAILY, + DATA_TARIFF_SENSORS, DATA_UTILITY, HOURLY, MONTHLY, @@ -96,19 +98,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_TARIFF_ENTITY ) conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) - - meters.append( - UtilityMeterSensor( - conf_meter_source, - conf.get(CONF_NAME), - conf_meter_type, - conf_meter_offset, - conf_meter_net_consumption, - conf.get(CONF_TARIFF), - conf_meter_tariff_entity, - conf_cron_pattern, - ) + meter_sensor = UtilityMeterSensor( + meter, + conf_meter_source, + conf.get(CONF_NAME), + conf_meter_type, + conf_meter_offset, + conf_meter_net_consumption, + conf.get(CONF_TARIFF), + conf_meter_tariff_entity, + conf_cron_pattern, ) + meters.append(meter_sensor) + + hass.data[DATA_UTILITY][meter][DATA_TARIFF_SENSORS].append(meter_sensor) async_add_entities(meters) @@ -126,6 +129,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def __init__( self, + parent_meter, source_entity, name, meter_type, @@ -136,8 +140,9 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): cron_pattern=None, ): """Initialize the Utility Meter sensor.""" + self._parent_meter = parent_meter self._sensor_source_id = source_entity - self._state = 0 + self._state = None self._last_period = 0 self._last_reset = dt_util.utcnow() self._collecting = None @@ -153,11 +158,26 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._tariff = tariff self._tariff_entity = tariff_entity + def start(self, unit): + """Initialize unit and state upon source initial update.""" + self._unit_of_measurement = unit + self._state = 0 + self.async_write_ha_state() + @callback def async_reading(self, event): """Handle the sensor state changes.""" old_state = event.data.get("old_state") new_state = event.data.get("new_state") + + if self._state is None and new_state.state: + # First state update initializes the utility_meter sensors + source_state = self.hass.states.get(self._sensor_source_id) + for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ + DATA_TARIFF_SENSORS + ]: + sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + if ( old_state is None or new_state is None @@ -304,15 +324,29 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if state: - self._state = Decimal(state.state) - self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._last_period = state.attributes.get(ATTR_LAST_PERIOD) - self._last_reset = dt_util.as_utc( - dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) - ) - if state.attributes.get(ATTR_STATUS) == COLLECTING: - # Fake cancellation function to init the meter in similar state - self._collecting = lambda: None + try: + self._state = Decimal(state.state) + except decimal.InvalidOperation: + _LOGGER.error( + "Could not restore state <%s>. Resetting utility_meter.%s", + state.state, + self.name, + ) + else: + self._unit_of_measurement = state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) + self._last_period = ( + float(state.attributes.get(ATTR_LAST_PERIOD)) + if state.attributes.get(ATTR_LAST_PERIOD) + else 0 + ) + self._last_reset = dt_util.as_utc( + dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) + ) + if state.attributes.get(ATTR_STATUS) == COLLECTING: + # Fake cancellation function to init the meter in similar state + self._collecting = lambda: None @callback def async_source_tracking(event): @@ -329,7 +363,12 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._change_status(tariff_entity_state.state) return - _LOGGER.debug("<%s> collecting from %s", self.name, self._sensor_source_id) + _LOGGER.debug( + "<%s> collecting %s from %s", + self.name, + self._unit_of_measurement, + self._sensor_source_id, + ) self._collecting = async_track_state_change_event( self.hass, [self._sensor_source_id], self.async_reading ) @@ -357,7 +396,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def state_class(self): """Return the device class of the sensor.""" return ( - STATE_CLASS_MEASUREMENT + STATE_CLASS_TOTAL if self._sensor_net_consumption else STATE_CLASS_TOTAL_INCREASING ) diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 9189568d2f4..f4fdbcf972e 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -74,7 +77,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "cleaning": diff --git a/homeassistant/components/vacuum/translations/fr.json b/homeassistant/components/vacuum/translations/fr.json index 1c069a98132..7bd851a3a8f 100644 --- a/homeassistant/components/vacuum/translations/fr.json +++ b/homeassistant/components/vacuum/translations/fr.json @@ -18,7 +18,7 @@ "cleaning": "Nettoyage", "docked": "Sur la base", "error": "Erreur", - "idle": "Inactif", + "idle": "En veille", "off": "Inactif", "on": "Actif", "paused": "En pause", diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 6f88afa66cf..bdd7242a76a 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -1,8 +1,10 @@ """Support for Vallox ventilation units.""" +from __future__ import annotations -from datetime import timedelta +from datetime import datetime import ipaddress import logging +from typing import Any from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox from vallox_websocket_api.constants import vlxDevConstants @@ -10,24 +12,29 @@ from vallox_websocket_api.exceptions import ValloxApiException import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, StateType + +from .const import ( + DEFAULT_FAN_SPEED_AWAY, + DEFAULT_FAN_SPEED_BOOST, + DEFAULT_FAN_SPEED_HOME, + DEFAULT_NAME, + DOMAIN, + METRIC_KEY_PROFILE_FAN_SPEED_AWAY, + METRIC_KEY_PROFILE_FAN_SPEED_BOOST, + METRIC_KEY_PROFILE_FAN_SPEED_HOME, + SIGNAL_VALLOX_STATE_UPDATE, + STATE_PROXY_SCAN_INTERVAL, + STR_TO_VALLOX_PROFILE_SETTABLE, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = "vallox" -DEFAULT_NAME = "Vallox" -SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update" -SCAN_INTERVAL = timedelta(seconds=60) - -# Various metric keys that are reused between profiles. -METRIC_KEY_MODE = "A_CYC_MODE" -METRIC_KEY_PROFILE_FAN_SPEED_HOME = "A_CYC_HOME_SPEED_SETTING" -METRIC_KEY_PROFILE_FAN_SPEED_AWAY = "A_CYC_AWAY_SPEED_SETTING" -METRIC_KEY_PROFILE_FAN_SPEED_BOOST = "A_CYC_BOOST_SPEED_SETTING" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -40,25 +47,15 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PROFILE_TO_STR_SETTABLE = { - VALLOX_PROFILE.HOME: "Home", - VALLOX_PROFILE.AWAY: "Away", - VALLOX_PROFILE.BOOST: "Boost", - VALLOX_PROFILE.FIREPLACE: "Fireplace", -} - -STR_TO_PROFILE = {v: k for (k, v) in PROFILE_TO_STR_SETTABLE.items()} - -PROFILE_TO_STR_REPORTABLE = { - **{VALLOX_PROFILE.NONE: "None", VALLOX_PROFILE.EXTRA: "Extra"}, - **PROFILE_TO_STR_SETTABLE, -} - ATTR_PROFILE = "profile" ATTR_PROFILE_FAN_SPEED = "fan_speed" SERVICE_SCHEMA_SET_PROFILE = vol.Schema( - {vol.Required(ATTR_PROFILE): vol.All(cv.string, vol.In(STR_TO_PROFILE))} + { + vol.Required(ATTR_PROFILE): vol.All( + cv.string, vol.In(STR_TO_VALLOX_PROFILE_SETTABLE) + ) + } ) SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema( @@ -93,12 +90,8 @@ SERVICE_TO_METHOD = { }, } -DEFAULT_FAN_SPEED_HOME = 50 -DEFAULT_FAN_SPEED_AWAY = 25 -DEFAULT_FAN_SPEED_BOOST = 65 - -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the client and boot the platforms.""" conf = config[DOMAIN] host = conf.get(CONF_HOST) @@ -116,17 +109,15 @@ async def async_setup(hass, config): DOMAIN, vallox_service, service_handler.async_handle, schema=schema ) - # The vallox hardware expects quite strict timings for websocket - # requests. Timings that machines with less processing power, like - # Raspberries, cannot live up to during the busy start phase of Home - # Asssistant. Hence, async_add_entities() for fan and sensor in respective - # code will be called with update_before_add=False to intentionally delay - # the first request, increasing chance that it is issued only when the - # machine is less busy again. + # The vallox hardware expects quite strict timings for websocket requests. Timings that machines + # with less processing power, like Raspberries, cannot live up to during the busy start phase of + # Home Asssistant. Hence, async_add_entities() for fan and sensor in respective code will be + # called with update_before_add=False to intentionally delay the first request, increasing + # chance that it is issued only when the machine is less busy again. hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config)) - async_track_time_interval(hass, state_proxy.async_update, SCAN_INTERVAL) + async_track_time_interval(hass, state_proxy.async_update, STATE_PROXY_SCAN_INTERVAL) return True @@ -134,15 +125,15 @@ async def async_setup(hass, config): class ValloxStateProxy: """Helper class to reduce websocket API calls.""" - def __init__(self, hass, client): + def __init__(self, hass: HomeAssistant, client: Vallox) -> None: """Initialize the proxy.""" self._hass = hass self._client = client - self._metric_cache = {} - self._profile = None + self._metric_cache: dict[str, Any] = {} + self._profile = VALLOX_PROFILE.NONE self._valid = False - def fetch_metric(self, metric_key): + def fetch_metric(self, metric_key: str) -> StateType: """Return cached state value.""" _LOGGER.debug("Fetching metric key: %s", metric_key) @@ -152,37 +143,47 @@ class ValloxStateProxy: if metric_key not in vlxDevConstants.__dict__: raise KeyError(f"Unknown metric key: {metric_key}") - return self._metric_cache[metric_key] + value = self._metric_cache[metric_key] + if value is None: + return None - def get_profile(self): + if not isinstance(value, (str, int, float)): + raise TypeError( + f"Return value of metric {metric_key} has unexpected type {type(value)}" + ) + + return value + + def get_profile(self) -> VALLOX_PROFILE: """Return cached profile value.""" _LOGGER.debug("Returning profile") if not self._valid: raise OSError("Device state out of sync.") - return PROFILE_TO_STR_REPORTABLE[self._profile] + return self._profile - async def async_update(self, event_time): + async def async_update(self, time: datetime | None = None) -> None: """Fetch state update.""" _LOGGER.debug("Updating Vallox state cache") try: self._metric_cache = await self._client.fetch_metrics() self._profile = await self._client.get_profile() - self._valid = True except (OSError, ValloxApiException) as err: - _LOGGER.error("Error during state cache update: %s", err) self._valid = False + _LOGGER.error("Error during state cache update: %s", err) + return + self._valid = True async_dispatcher_send(self._hass, SIGNAL_VALLOX_STATE_UPDATE) class ValloxServiceHandler: """Services implementation.""" - def __init__(self, client, state_proxy): + def __init__(self, client: Vallox, state_proxy: ValloxStateProxy) -> None: """Initialize the proxy.""" self._client = client self._state_proxy = state_proxy @@ -191,8 +192,13 @@ class ValloxServiceHandler: """Set the ventilation profile.""" _LOGGER.debug("Setting ventilation profile to: %s", profile) + _LOGGER.warning( + "Attention: The service 'vallox.set_profile' is superseded by the 'fan.set_preset_mode' service." + "It will be removed in the future, please migrate to 'fan.set_preset_mode' to prevent breakage" + ) + try: - await self._client.set_profile(STR_TO_PROFILE[profile]) + await self._client.set_profile(STR_TO_VALLOX_PROFILE_SETTABLE[profile]) return True except (OSError, ValloxApiException) as err: @@ -218,7 +224,7 @@ class ValloxServiceHandler: async def async_set_profile_fan_speed_away( self, fan_speed: int = DEFAULT_FAN_SPEED_AWAY ) -> bool: - """Set the fan speed in percent for the Home profile.""" + """Set the fan speed in percent for the Away profile.""" _LOGGER.debug("Setting Away fan speed to: %d%%", fan_speed) try: @@ -247,10 +253,13 @@ class ValloxServiceHandler: _LOGGER.error("Error setting fan speed for Boost profile: %s", err) return False - async def async_handle(self, service): + async def async_handle(self, call: ServiceCall) -> None: """Dispatch a service call.""" - method = SERVICE_TO_METHOD.get(service.service) - params = service.data.copy() + method = SERVICE_TO_METHOD.get(call.service) + params = call.data.copy() + + if method is None: + return if not hasattr(self, method["method"]): _LOGGER.error("Service not implemented: %s", method["method"]) @@ -258,7 +267,7 @@ class ValloxServiceHandler: result = await getattr(self, method["method"])(**params) - # Force state_proxy to refresh device state, so that updates are - # propagated to platforms. + # This state change affects other entities like sensors. Force an immediate update that can + # be observed by all parties involved. if result: - await self._state_proxy.async_update(None) + await self._state_proxy.async_update() diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py new file mode 100644 index 00000000000..6a9c4ddc5f4 --- /dev/null +++ b/homeassistant/components/vallox/const.py @@ -0,0 +1,40 @@ +"""Constants for the Vallox integration.""" + +from datetime import timedelta + +from vallox_websocket_api import PROFILE as VALLOX_PROFILE + +DOMAIN = "vallox" +DEFAULT_NAME = "Vallox" + +SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update" +STATE_PROXY_SCAN_INTERVAL = timedelta(seconds=60) + +# Common metric keys and (default) values. +METRIC_KEY_MODE = "A_CYC_MODE" +METRIC_KEY_PROFILE_FAN_SPEED_HOME = "A_CYC_HOME_SPEED_SETTING" +METRIC_KEY_PROFILE_FAN_SPEED_AWAY = "A_CYC_AWAY_SPEED_SETTING" +METRIC_KEY_PROFILE_FAN_SPEED_BOOST = "A_CYC_BOOST_SPEED_SETTING" + +MODE_ON = 0 +MODE_OFF = 5 + +DEFAULT_FAN_SPEED_HOME = 50 +DEFAULT_FAN_SPEED_AWAY = 25 +DEFAULT_FAN_SPEED_BOOST = 65 + +VALLOX_PROFILE_TO_STR_SETTABLE = { + VALLOX_PROFILE.HOME: "Home", + VALLOX_PROFILE.AWAY: "Away", + VALLOX_PROFILE.BOOST: "Boost", + VALLOX_PROFILE.FIREPLACE: "Fireplace", +} + +VALLOX_PROFILE_TO_STR_REPORTABLE = { + VALLOX_PROFILE.EXTRA: "Extra", + **VALLOX_PROFILE_TO_STR_SETTABLE, +} + +STR_TO_VALLOX_PROFILE_SETTABLE = { + value: key for (key, value) in VALLOX_PROFILE_TO_STR_SETTABLE.items() +} diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index e167791e702..8ee1b8b471f 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -1,23 +1,39 @@ """Support for the Vallox ventilation unit fan.""" +from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any -from homeassistant.components.fan import FanEntity -from homeassistant.core import callback +from vallox_websocket_api import Vallox +from vallox_websocket_api.exceptions import ValloxApiException + +from homeassistant.components.fan import ( + SUPPORT_PRESET_MODE, + FanEntity, + NotValidPresetModeError, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import ValloxStateProxy +from .const import ( DOMAIN, METRIC_KEY_MODE, METRIC_KEY_PROFILE_FAN_SPEED_AWAY, METRIC_KEY_PROFILE_FAN_SPEED_BOOST, METRIC_KEY_PROFILE_FAN_SPEED_HOME, + MODE_OFF, + MODE_ON, SIGNAL_VALLOX_STATE_UPDATE, + STR_TO_VALLOX_PROFILE_SETTABLE, + VALLOX_PROFILE_TO_STR_SETTABLE, ) _LOGGER = logging.getLogger(__name__) -# Device attributes ATTR_PROFILE_FAN_SPEED_HOME = { "description": "fan_speed_home", "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_HOME, @@ -32,13 +48,17 @@ ATTR_PROFILE_FAN_SPEED_BOOST = { } -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 fan device.""" if discovery_info is None: return client = hass.data[DOMAIN]["client"] - client.set_settable_address(METRIC_KEY_MODE, int) device = ValloxFan( @@ -51,39 +71,46 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class ValloxFan(FanEntity): """Representation of the fan.""" - def __init__(self, name, client, state_proxy): + _attr_should_poll = False + + def __init__( + self, name: str, client: Vallox, state_proxy: ValloxStateProxy + ) -> None: """Initialize the fan.""" - self._name = name self._client = client self._state_proxy = state_proxy - self._available = False - self._state = None - self._fan_speed_home = None - self._fan_speed_away = None - self._fan_speed_boost = None + self._is_on = False + self._preset_mode: str | None = None + self._fan_speed_home: int | None = None + self._fan_speed_away: int | None = None + self._fan_speed_boost: int | None = None + + self._attr_name = name + self._attr_available = False @property - def should_poll(self): - """Do not poll the device.""" - return False + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_PRESET_MODE @property - def name(self): - """Return the name of the device.""" - return self._name + def preset_modes(self) -> list[str]: + """Return a list of available preset modes.""" + # Use the Vallox profile names for the preset names. + return list(STR_TO_VALLOX_PROFILE_SETTABLE.keys()) @property - def available(self): - """Return if state is known.""" - return self._available - - @property - def is_on(self): + def is_on(self) -> bool: """Return if device is on.""" - return self._state + return self._is_on @property - def extra_state_attributes(self): + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + + @property + def extra_state_attributes(self) -> Mapping[str, int | None]: """Return device specific state attributes.""" return { ATTR_PROFILE_FAN_SPEED_HOME["description"]: self._fan_speed_home, @@ -91,7 +118,7 @@ class ValloxFan(FanEntity): ATTR_PROFILE_FAN_SPEED_BOOST["description"]: self._fan_speed_boost, } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" self.async_on_remove( async_dispatcher_connect( @@ -100,91 +127,123 @@ class ValloxFan(FanEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the device.""" try: # Fetch if the whole device is in regular operation state. - mode = self._state_proxy.fetch_metric(METRIC_KEY_MODE) - if mode == 0: - self._state = True - else: - self._state = False + self._is_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON + + vallox_profile = self._state_proxy.get_profile() # Fetch the profile fan speeds. - self._fan_speed_home = int( - self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_HOME["metric_key"] - ) + fan_speed_home = self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_HOME["metric_key"] ) - self._fan_speed_away = int( - self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_AWAY["metric_key"] - ) + fan_speed_away = self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_AWAY["metric_key"] ) - self._fan_speed_boost = int( - self._state_proxy.fetch_metric( - ATTR_PROFILE_FAN_SPEED_BOOST["metric_key"] - ) + fan_speed_boost = self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_BOOST["metric_key"] ) - self._available = True - - except (OSError, KeyError) as err: - self._available = False + except (OSError, KeyError, TypeError) as err: + self._attr_available = False _LOGGER.error("Error updating fan: %s", err) + return + + self._preset_mode = VALLOX_PROFILE_TO_STR_SETTABLE.get(vallox_profile) + + self._fan_speed_home = ( + int(fan_speed_home) if isinstance(fan_speed_home, (int, float)) else None + ) + self._fan_speed_away = ( + int(fan_speed_away) if isinstance(fan_speed_away, (int, float)) else None + ) + self._fan_speed_boost = ( + int(fan_speed_boost) if isinstance(fan_speed_boost, (int, float)) else None + ) + + self._attr_available = True + + async def _async_set_preset_mode_internal(self, preset_mode: str) -> bool: + """ + Set new preset mode. + + Returns true if the mode has been changed, false otherwise. + """ + try: + self._valid_preset_mode_or_raise(preset_mode) # type: ignore[no-untyped-call] + + except NotValidPresetModeError as err: + _LOGGER.error(err) + return False + + if preset_mode == self.preset_mode: + return False + + try: + await self._client.set_profile(STR_TO_VALLOX_PROFILE_SETTABLE[preset_mode]) + + except (OSError, ValloxApiException) as err: + _LOGGER.error("Error setting preset: %s", err) + return False + + return True + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + update_needed = await self._async_set_preset_mode_internal(preset_mode) + + if update_needed: + # This state change affects other entities like sensors. Force an immediate update that + # can be observed by all parties involved. + await self._state_proxy.async_update() - # - # The fan entity model has changed to use percentages and preset_modes - # instead of speeds. - # - # Please review - # https://developers.home-assistant.io/docs/core/entity/fan/ - # async def async_turn_on( self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, - **kwargs, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the device on.""" _LOGGER.debug("Turn on: %s", speed) - # Only the case speed == None equals the GUI toggle switch being - # activated. - if speed is not None: + update_needed = False + + if preset_mode: + update_needed = await self._async_set_preset_mode_internal(preset_mode) + + if not self.is_on: + try: + await self._client.set_values({METRIC_KEY_MODE: MODE_ON}) + + except OSError as err: + _LOGGER.error("Error turning on: %s", err) + + else: + update_needed = True + + if update_needed: + # This state change affects other entities like sensors. Force an immediate update that + # can be observed by all parties involved. + await self._state_proxy.async_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + if not self.is_on: return - if self._state is False: - try: - await self._client.set_values({METRIC_KEY_MODE: 0}) + try: + await self._client.set_values({METRIC_KEY_MODE: MODE_OFF}) - # This state change affects other entities like sensors. Force - # an immediate update that can be observed by all parties - # involved. - await self._state_proxy.async_update(None) + except OSError as err: + _LOGGER.error("Error turning off: %s", err) + return - except OSError as err: - self._available = False - _LOGGER.error("Error turning on: %s", err) - else: - _LOGGER.error("Already on") - - async def async_turn_off(self, **kwargs) -> None: - """Turn the device off.""" - if self._state is True: - try: - await self._client.set_values({METRIC_KEY_MODE: 5}) - - # Same as for turn_on method. - await self._state_proxy.async_update(None) - - except OSError as err: - self._available = False - _LOGGER.error("Error turning off: %s", err) - else: - _LOGGER.error("Already off") + # Same as for turn_on method. + await self._state_proxy.async_update() diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index b536270c336..c4b25644ed0 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -3,6 +3,6 @@ "name": "Vallox", "documentation": "https://www.home-assistant.io/integrations/vallox", "requirements": ["vallox-websocket-api==2.8.1"], - "codeowners": [], + "codeowners": ["@andre-richter"], "iot_class": "local_polling" } diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 4e4dc6cdddf..ff22c317bc1 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -19,10 +19,19 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) -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.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE, ValloxStateProxy +from . import ValloxStateProxy +from .const import ( + DOMAIN, + METRIC_KEY_MODE, + MODE_ON, + SIGNAL_VALLOX_STATE_UPDATE, + VALLOX_PROFILE_TO_STR_REPORTABLE, +) _LOGGER = logging.getLogger(__name__) @@ -47,7 +56,7 @@ class ValloxSensor(SensorEntity): self._attr_name = f"{name} {description.name}" self._attr_available = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" self.async_on_remove( async_dispatcher_connect( @@ -56,84 +65,99 @@ class ValloxSensor(SensorEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" - try: - self._attr_native_value = self._state_proxy.fetch_metric( - self.entity_description.metric_key - ) - self._attr_available = True + metric_key = self.entity_description.metric_key - except (OSError, KeyError) as err: + if metric_key is None: + self._attr_available = False + _LOGGER.error("Error updating sensor. Empty metric key") + return + + try: + self._attr_native_value = self._state_proxy.fetch_metric(metric_key) + + except (OSError, KeyError, TypeError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + self._attr_available = True class ValloxProfileSensor(ValloxSensor): """Child class for profile reporting.""" - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" try: - self._attr_native_value = self._state_proxy.get_profile() - self._attr_available = True + vallox_profile = self._state_proxy.get_profile() except OSError as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + self._attr_native_value = VALLOX_PROFILE_TO_STR_REPORTABLE.get(vallox_profile) + self._attr_available = True -# There seems to be a quirk with respect to the fan speed reporting. The device -# keeps on reporting the last valid fan speed from when the device was in -# regular operation mode, even if it left that state and has been shut off in -# the meantime. +# There seems to be a quirk with respect to the fan speed reporting. The device keeps on reporting +# the last valid fan speed from when the device was in regular operation mode, even if it left that +# state and has been shut off in the meantime. # -# Therefore, first query the overall state of the device, and report zero -# percent fan speed in case it is not in regular operation mode. +# Therefore, first query the overall state of the device, and report zero percent fan speed in case +# it is not in regular operation mode. class ValloxFanSpeedSensor(ValloxSensor): """Child class for fan speed reporting.""" - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" try: - # If device is in regular operation, continue. - if self._state_proxy.fetch_metric(METRIC_KEY_MODE) == 0: - await super().async_update() - else: - # Report zero percent otherwise. - self._attr_native_value = 0 - self._attr_available = True + fan_on = self._state_proxy.fetch_metric(METRIC_KEY_MODE) == MODE_ON - except (OSError, KeyError) as err: + except (OSError, KeyError, TypeError) as err: self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + return + + if fan_on: + await super().async_update() + else: + # Report zero percent otherwise. + self._attr_native_value = 0 + self._attr_available = True class ValloxFilterRemainingSensor(ValloxSensor): """Child class for filter remaining time reporting.""" - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the ventilation unit.""" - try: - days_remaining = int( - self._state_proxy.fetch_metric(self.entity_description.metric_key) - ) - days_remaining_delta = timedelta(days=days_remaining) + await super().async_update() - # Since only a delta of days is received from the device, fix the - # time so the timestamp does not change with every update. - now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) + # Check if the update in the super call was a success. + if not self._attr_available: + return - self._attr_native_value = (now + days_remaining_delta).isoformat() - self._attr_available = True - - except (OSError, KeyError) as err: + if not isinstance(self._attr_native_value, (int, float)): self._attr_available = False - _LOGGER.error("Error updating sensor: %s", err) + _LOGGER.error( + "Value has unexpected type: %s", type(self._attr_native_value) + ) + return + + # Since only a delta of days is received from the device, fix the time so the timestamp does + # not change with every update. + days_remaining = float(self._attr_native_value) + days_remaining_delta = timedelta(days=days_remaining) + now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) + + self._attr_native_value = (now + days_remaining_delta).isoformat() @dataclass @@ -226,7 +250,12 @@ SENSORS: tuple[ValloxSensorEntityDescription, ...] = ( ) -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 sensors.""" if discovery_info is None: return diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml index 98d7abac249..5cfa1dae4b5 100644 --- a/homeassistant/components/vallox/services.yaml +++ b/homeassistant/components/vallox/services.yaml @@ -15,7 +15,7 @@ set_profile: - 'Home' set_profile_fan_speed_home: - name: Set profile fan speed hom + name: Set profile fan speed home description: Set the fan speed of the Home profile. fields: fan_speed: diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index b798023c465..265167f574c 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -1,22 +1,28 @@ """Support for Velbus devices.""" +from __future__ import annotations + import logging -import velbus +from velbusaio.controller import Velbus import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from .const import CONF_MEMO_TEXT, DOMAIN, SERVICE_SET_MEMO_TEXT +from .const import ( + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) _LOGGER = logging.getLogger(__name__) -VELBUS_MESSAGE = "velbus.message" - CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_PORT): cv.string})}, extra=vol.ALLOW_EXTRA ) @@ -29,6 +35,9 @@ async def async_setup(hass, config): # Import from the configuration file if needed if DOMAIN not in config: return True + + _LOGGER.warning("Loading VELBUS via configuration.yaml is deprecated") + port = config[DOMAIN].get(CONF_PORT) data = {} @@ -39,57 +48,70 @@ async def async_setup(hass, config): DOMAIN, context={"source": SOURCE_IMPORT}, data=data ) ) - return True +async def velbus_connect_task( + controller: Velbus, hass: HomeAssistant, entry_id: str +) -> None: + """Task to offload the long running connect.""" + await controller.connect() + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with velbus.""" hass.data.setdefault(DOMAIN, {}) - def callback(): - modules = controller.get_modules() - discovery_info = {"cntrl": controller} - for platform in PLATFORMS: - discovery_info[platform] = [] - for module in modules: - for channel in range(1, module.number_of_channels() + 1): - for platform in PLATFORMS: - if platform in module.get_categories(channel): - discovery_info[platform].append( - (module.get_module_address(), channel) - ) - hass.data[DOMAIN][entry.entry_id] = discovery_info + controller = Velbus( + entry.data[CONF_PORT], + cache_dir=hass.config.path(".storage/velbuscache/"), + ) + hass.data[DOMAIN][entry.entry_id] = {} + hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller + hass.data[DOMAIN][entry.entry_id]["tsk"] = hass.async_create_task( + velbus_connect_task(controller, hass, entry.entry_id) + ) - for platform in PLATFORMS: - hass.add_job(hass.config_entries.async_forward_entry_setup(entry, platform)) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - try: - controller = velbus.Controller(entry.data[CONF_PORT]) - controller.scan(callback) - except velbus.util.VelbusException as err: - _LOGGER.error("An error occurred: %s", err) - raise ConfigEntryNotReady from err + if hass.services.has_service(DOMAIN, SERVICE_SCAN): + return True - def syn_clock(self, service=None): - try: - controller.sync_clock() - except velbus.util.VelbusException as err: - _LOGGER.error("An error occurred: %s", err) + def check_entry_id(interface: str): + for entry in hass.config_entries.async_entries(DOMAIN): + if "port" in entry.data and entry.data["port"] == interface: + return entry.entry_id + raise vol.Invalid( + "The interface provided is not defined as a port in a Velbus integration" + ) - hass.services.async_register(DOMAIN, "sync_clock", syn_clock, schema=vol.Schema({})) + async def scan(call): + await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].scan() - def set_memo_text(service): + hass.services.async_register( + DOMAIN, + SERVICE_SCAN, + scan, + vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + ) + + async def syn_clock(call): + await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].sync_clock() + + hass.services.async_register( + DOMAIN, + SERVICE_SYNC, + syn_clock, + vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + ) + + async def set_memo_text(call): """Handle Memo Text service call.""" - module_address = service.data[CONF_ADDRESS] - memo_text = service.data[CONF_MEMO_TEXT] + memo_text = call.data[CONF_MEMO_TEXT] memo_text.hass = hass - try: - controller.get_module(module_address).set_memo_text( - memo_text.async_render() - ) - except velbus.util.VelbusException as err: - _LOGGER.error("An error occurred while setting memo text: %s", err) + await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].get_module( + call.data[CONF_ADDRESS] + ).set_memo_text(memo_text.async_render()) hass.services.async_register( DOMAIN, @@ -97,6 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: set_memo_text, vol.Schema( { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), vol.Required(CONF_ADDRESS): vol.All( vol.Coerce(int), vol.Range(min=0, max=255) ), @@ -111,35 +134,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Remove the velbus connection.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() + await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) + hass.services.async_remove(DOMAIN, SERVICE_SCAN) + hass.services.async_remove(DOMAIN, SERVICE_SYNC) + hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT) return unload_ok class VelbusEntity(Entity): """Representation of a Velbus entity.""" - def __init__(self, module, channel): + def __init__(self, channel): """Initialize a Velbus entity.""" - self._module = module self._channel = channel @property def unique_id(self): """Get unique ID.""" - serial = 0 - if self._module.serial == 0: - serial = self._module.get_module_address() - else: - serial = self._module.serial - return f"{serial}-{self._channel}" + if (serial := self._channel.get_module_serial()) == 0: + serial = self._channel.get_module_address() + return f"{serial}-{self._channel.get_channel_number()}" @property def name(self): """Return the display name of this entity.""" - return self._module.get_name(self._channel) + return self._channel.get_name() @property def should_poll(self): @@ -148,26 +170,24 @@ class VelbusEntity(Entity): async def async_added_to_hass(self): """Add listener for state changes.""" - self._module.on_status_update(self._channel, self._on_update) + self._channel.on_status_update(self._on_update) - def _on_update(self, state): - self.schedule_update_ha_state() + async def _on_update(self): + self.async_write_ha_state() @property def device_info(self): """Return the device info.""" return { "identifiers": { - (DOMAIN, self._module.get_module_address(), self._module.serial) + ( + DOMAIN, + self._channel.get_module_address(), + self._channel.get_module_serial(), + ) }, - "name": "{} ({})".format( - self._module.get_module_name(), self._module.get_module_address() - ), + "name": self._channel.get_full_name(), "manufacturer": "Velleman", - "model": self._module.get_module_type_name(), - "sw_version": "{}.{}-{}".format( - self._module.memory_map_version, - self._module.build_year, - self._module.build_week, - ), + "model": self._channel.get_module_type_name(), + "sw_version": self._channel.get_module_sw_version(), } diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 74263d87234..be5d8d24698 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -6,13 +6,12 @@ from .const import DOMAIN async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus binary sensor based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["binary_sensor"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusBinarySensor(module, channel)) + for channel in cntrl.get_all("binary_sensor"): + entities.append(VelbusBinarySensor(channel)) async_add_entities(entities) @@ -20,6 +19,6 @@ class VelbusBinarySensor(VelbusEntity, BinarySensorEntity): """Representation of a Velbus Binary Sensor.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the sensor is on.""" - return self._module.is_closed(self._channel) + return self._channel.is_closed() diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index 6ef91d65c91..68d92bf43d0 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -1,14 +1,12 @@ """Support for Velbus thermostat.""" import logging -from velbus.util import VelbusException - from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from . import VelbusEntity from .const import DOMAIN @@ -17,13 +15,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus binary sensor based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["climate"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusClimate(module, channel)) + for channel in cntrl.get_all("climate"): + entities.append(VelbusClimate(channel)) async_add_entities(entities) @@ -37,15 +34,13 @@ class VelbusClimate(VelbusEntity, ClimateEntity): @property def temperature_unit(self): - """Return the unit this state is expressed in.""" - if self._module.get_unit(self._channel) == TEMP_CELSIUS: - return TEMP_CELSIUS - return TEMP_FAHRENHEIT + """Return the unit.""" + return TEMP_CELSIUS @property def current_temperature(self): """Return the current temperature.""" - return self._module.get_state(self._channel) + return self._channel.get_state() @property def hvac_mode(self): @@ -66,18 +61,14 @@ class VelbusClimate(VelbusEntity, ClimateEntity): @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._module.get_climate_target() + return self._channel.get_climate_target() def set_temperature(self, **kwargs): """Set new target temperatures.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: return - try: - self._module.set_temp(temp) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) - return + self._channel.set_temp(temp) self.schedule_update_ha_state() def set_hvac_mode(self, hvac_mode): diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 93dd68c9eea..3ec5af14397 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -1,7 +1,8 @@ """Config flow for the Velbus platform.""" from __future__ import annotations -import velbus +import velbusaio +from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol from homeassistant import config_entries @@ -33,14 +34,15 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Create an entry async.""" return self.async_create_entry(title=name, data={CONF_PORT: prt}) - def _test_connection(self, prt): + async def _test_connection(self, prt): """Try to connect to the velbus with the port specified.""" try: - controller = velbus.Controller(prt) - except Exception: # pylint: disable=broad-except + controller = velbusaio.controller.Velbus(prt) + await controller.connect(True) + await controller.stop() + except VelbusConnectionFailed: self._errors[CONF_PORT] = "cannot_connect" return False - controller.stop() return True def _prt_in_configuration_exists(self, prt: str) -> bool: @@ -56,7 +58,7 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): name = slugify(user_input[CONF_NAME]) prt = user_input[CONF_PORT] if not self._prt_in_configuration_exists(prt): - if self._test_connection(prt): + if await self._test_connection(prt): return self._create_device(name, prt) else: self._errors[CONF_PORT] = "already_configured" diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index d3987295fce..69c0c926136 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -2,6 +2,9 @@ DOMAIN = "velbus" +CONF_INTERFACE = "interface" CONF_MEMO_TEXT = "memo_text" +SERVICE_SCAN = "scan" +SERVICE_SYNC = "sync_clock" SERVICE_SET_MEMO_TEXT = "set_memo_text" diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index efe4fdc964b..1003d341c93 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -1,8 +1,6 @@ """Support for Velbus covers.""" import logging -from velbus.util import VelbusException - from homeassistant.components.cover import ( ATTR_POSITION, SUPPORT_CLOSE, @@ -19,13 +17,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus cover based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["cover"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusCover(module, channel)) + for channel in cntrl.get_all("cover"): + entities.append(VelbusCover(channel)) async_add_entities(entities) @@ -35,16 +32,14 @@ class VelbusCover(VelbusEntity, CoverEntity): @property def supported_features(self): """Flag supported features.""" - if self._module.support_position(): + if self._channel.support_position(): return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP @property def is_closed(self): """Return if the cover is closed.""" - if self._module.get_position(self._channel) == 100: - return True - return False + return self._channel.is_closed() @property def current_cover_position(self): @@ -53,33 +48,21 @@ class VelbusCover(VelbusEntity, CoverEntity): None is unknown, 0 is closed, 100 is fully open Velbus: 100 = closed, 0 = open """ - pos = self._module.get_position(self._channel) + pos = self._channel.get_position() return 100 - pos - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" - try: - self._module.open(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.open() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" - try: - self._module.close(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.close() - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - try: - self._module.stop(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.stop() - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - try: - self._module.set(self._channel, (100 - kwargs[ATTR_POSITION])) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + self._channel.set_position(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 4aebbb27953..482bdb53e94 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -1,8 +1,6 @@ """Support for Velbus light.""" import logging -from velbus.util import VelbusException - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_FLASH, @@ -22,62 +20,61 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus light based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["light"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusLight(module, channel)) + for channel in cntrl.get_all("light"): + entities.append(VelbusLight(channel, False)) + for channel in cntrl.get_all("led"): + entities.append(VelbusLight(channel, True)) async_add_entities(entities) class VelbusLight(VelbusEntity, LightEntity): """Representation of a Velbus light.""" + def __init__(self, channel, led): + """Initialize a light Velbus entity.""" + super().__init__(channel) + self._is_led = led + @property def name(self): """Return the display name of this entity.""" - if self._module.light_is_buttonled(self._channel): - return f"LED {self._module.get_name(self._channel)}" - return self._module.get_name(self._channel) + if self._is_led: + return f"LED {self._channel.get_name()}" + return self._channel.get_name() @property def supported_features(self): """Flag supported features.""" - if self._module.light_is_buttonled(self._channel): + if self._is_led: return SUPPORT_FLASH return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION - @property - def entity_registry_enabled_default(self): - """Disable Button LEDs by default.""" - if self._module.light_is_buttonled(self._channel): - return False - return True - @property def is_on(self): """Return true if the light is on.""" - return self._module.is_on(self._channel) + return self._channel.is_on() @property def brightness(self): """Return the brightness of the light.""" - return int((self._module.get_dimmer_state(self._channel) * 255) / 100) + return int((self._channel.get_dimmer_state() * 255) / 100) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the Velbus light to turn on.""" - if self._module.light_is_buttonled(self._channel): + if self._is_led: if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", self._channel, "slow" + attr, *args = "set_led_state", "slow" elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", self._channel, "fast" + attr, *args = "set_led_state", "fast" else: - attr, *args = "set_led_state", self._channel, "on" + attr, *args = "set_led_state", "on" else: - attr, *args = "set_led_state", self._channel, "on" + attr, *args = "set_led_state", "on" else: if ATTR_BRIGHTNESS in kwargs: # Make sure a low but non-zero value is not rounded down to zero @@ -87,33 +84,24 @@ class VelbusLight(VelbusEntity, LightEntity): brightness = max(int((kwargs[ATTR_BRIGHTNESS] * 100) / 255), 1) attr, *args = ( "set_dimmer_state", - self._channel, brightness, kwargs.get(ATTR_TRANSITION, 0), ) else: attr, *args = ( "restore_dimmer_state", - self._channel, kwargs.get(ATTR_TRANSITION, 0), ) - try: - getattr(self._module, attr)(*args) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await getattr(self._channel, attr)(*args) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the velbus light to turn off.""" - if self._module.light_is_buttonled(self._channel): - attr, *args = "set_led_state", self._channel, "off" + if self._is_led: + attr, *args = "set_led_state", "off" else: attr, *args = ( "set_dimmer_state", - self._channel, 0, kwargs.get(ATTR_TRANSITION, 0), ) - try: - getattr(self._module, attr)(*args) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await getattr(self._channel, attr)(*args) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ba99415944d..27ffbfd10de 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,7 +2,7 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["python-velbus==2.1.2"], + "requirements": ["velbus-aio==2021.9.4"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "iot_class": "local_push" diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 3a4aa2302f6..32f016b8ce3 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -1,30 +1,39 @@ """Support for Velbus sensors.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR +from __future__ import annotations + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, +) from . import VelbusEntity from .const import DOMAIN async def async_setup_entry(hass, entry, async_add_entities): - """Set up Velbus sensor based on config_entry.""" + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["sensor"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusSensor(module, channel)) - if module.get_class(channel) == "counter": - entities.append(VelbusSensor(module, channel, True)) + for channel in cntrl.get_all("sensor"): + entities.append(VelbusSensor(channel)) + if channel.is_counter_channel(): + entities.append(VelbusSensor(channel, True)) async_add_entities(entities) class VelbusSensor(VelbusEntity, SensorEntity): """Representation of a sensor.""" - def __init__(self, module, channel, counter=False): + def __init__(self, channel, counter=False): """Initialize a sensor Velbus entity.""" - super().__init__(module, channel) + super().__init__(channel) self._is_counter = counter @property @@ -35,28 +44,38 @@ class VelbusSensor(VelbusEntity, SensorEntity): unique_id = f"{unique_id}-counter" return unique_id + @property + def name(self): + """Return the name for the sensor.""" + name = super().name + if self._is_counter: + name = f"{name}-counter" + return name + @property def device_class(self): """Return the device class of the sensor.""" - if self._module.get_class(self._channel) == "counter" and not self._is_counter: - if self._module.get_counter_unit(self._channel) == ENERGY_KILO_WATT_HOUR: - return DEVICE_CLASS_POWER - return None - return self._module.get_class(self._channel) + if self._is_counter: + return DEVICE_CLASS_ENERGY + if self._channel.is_counter_channel(): + return DEVICE_CLASS_POWER + if self._channel.is_temperature(): + return DEVICE_CLASS_TEMPERATURE + return None @property def native_value(self): """Return the state of the sensor.""" if self._is_counter: - return self._module.get_counter_state(self._channel) - return self._module.get_state(self._channel) + return self._channel.get_counter_state() + return self._channel.get_state() @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" if self._is_counter: - return self._module.get_counter_unit(self._channel) - return self._module.get_unit(self._channel) + return self._channel.get_counter_unit() + return self._channel.get_unit() @property def icon(self): @@ -64,3 +83,10 @@ class VelbusSensor(VelbusEntity, SensorEntity): if self._is_counter: return "mdi:counter" return None + + @property + def state_class(self): + """Return the state class of this device.""" + if self._is_counter: + return STATE_CLASS_TOTAL_INCREASING + return STATE_CLASS_MEASUREMENT diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 9fed172fad4..83af09409c1 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,6 +1,28 @@ sync_clock: name: Sync clock description: Sync the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink + fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: '' + selector: + text: + +scan: + name: Scan + description: Scan the velbus modules, this will be need if you see unknown module warnings in the logs, or when you added new modules + fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: '' + selector: + text: set_memo_text: name: Set memo text @@ -8,6 +30,14 @@ set_memo_text: Set the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text. fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: '' + selector: + text: address: name: Address description: > @@ -16,8 +46,8 @@ set_memo_text: required: true selector: number: - min: 0 - max: 255 + min: 1 + max: 254 memo_text: name: Memo text description: > diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 91746b1513e..6b9609cc857 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -1,7 +1,6 @@ """Support for Velbus switches.""" import logging - -from velbus.util import VelbusException +from typing import Any from homeassistant.components.switch import SwitchEntity @@ -13,12 +12,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - modules_data = hass.data[DOMAIN][entry.entry_id]["switch"] entities = [] - for address, channel in modules_data: - module = cntrl.get_module(address) - entities.append(VelbusSwitch(module, channel)) + for channel in cntrl.get_all("switch"): + entities.append(VelbusSwitch(channel)) async_add_entities(entities) @@ -26,20 +24,14 @@ class VelbusSwitch(VelbusEntity, SwitchEntity): """Representation of a switch.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the switch is on.""" - return self._module.is_on(self._channel) + return self._channel.is_on() - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" - try: - self._module.turn_on(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.turn_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" - try: - self._module.turn_off(self._channel) - except VelbusException as err: - _LOGGER.error("A Velbus error occurred: %s", err) + await self._channel.turn_off() diff --git a/homeassistant/components/vera/translations/hu.json b/homeassistant/components/vera/translations/hu.json index 1f1e22b9ed8..d1d4910c97a 100644 --- a/homeassistant/components/vera/translations/hu.json +++ b/homeassistant/components/vera/translations/hu.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "cannot_connect": "Nem siker\u00fclt csatlakozni a {base_url}" + "cannot_connect": "Nem siker\u00fclt csatlakozni: {base_url}" }, "step": { "user": { "data": { - "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa a HomeAssistantb\u00f3l.", - "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a HomeAssistant alkalmaz\u00e1sban.", + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistantb\u00f3l.", + "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a Home Assistant alkalmaz\u00e1sban.", "vera_controller_url": "Vez\u00e9rl\u0151 URL" }, - "description": "Adja meg a Vera vez\u00e9rl\u0151 URL-j\u00e9t al\u00e1bb. Ennek \u00edgy kell kin\u00e9znie: http://192.168.1.161:3480.", + "description": "Adja meg a Vera vez\u00e9rl\u0151 URL-j\u00e9t al\u00e1bb. Hasonl\u00f3k\u00e9ppen kell kin\u00e9znie: http://192.168.1.161:3480.", "title": "Vera vez\u00e9rl\u0151 be\u00e1ll\u00edt\u00e1sa" } } @@ -19,8 +19,8 @@ "step": { "init": { "data": { - "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa a HomeAssistantb\u00f3l.", - "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a HomeAssistant alkalmaz\u00e1sban." + "exclude": "Vera eszk\u00f6zazonos\u00edt\u00f3k kiz\u00e1r\u00e1sa Home Assistantb\u00f3l.", + "lights": "A Vera kapcsol\u00f3eszk\u00f6z-azonos\u00edt\u00f3k f\u00e9nyk\u00e9nt kezelhet\u0151k a Home Assistant alkalmaz\u00e1sban." }, "description": "Az opcion\u00e1lis param\u00e9terekr\u0151l a vera dokument\u00e1ci\u00f3j\u00e1ban olvashat: https://www.home-assistant.io/integrations/vera/. Megjegyz\u00e9s: Az itt v\u00e9grehajtott v\u00e1ltoztat\u00e1sokhoz \u00fajra kell ind\u00edtani a h\u00e1zi asszisztens szervert. Az \u00e9rt\u00e9kek t\u00f6rl\u00e9s\u00e9hez adjon meg egy sz\u00f3k\u00f6zt.", "title": "Vera vez\u00e9rl\u0151 opci\u00f3k" diff --git a/homeassistant/components/verisure/translations/ca.json b/homeassistant/components/verisure/translations/ca.json index 0ddcf9513f4..c27943b35f3 100644 --- a/homeassistant/components/verisure/translations/ca.json +++ b/homeassistant/components/verisure/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat", + "already_configured": "El compte ja est\u00e0 configurat", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/verisure/translations/hu.json b/homeassistant/components/verisure/translations/hu.json index f071872b81c..89ff19bd1fe 100644 --- a/homeassistant/components/verisure/translations/hu.json +++ b/homeassistant/components/verisure/translations/hu.json @@ -13,7 +13,7 @@ "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." + "description": "Home Assistant t\u00f6bb Verisure telep\u00edt\u00e9st tal\u00e1lt a Saj\u00e1t oldalak fi\u00f3kj\u00e1ban. K\u00e9rj\u00fck, v\u00e1lassza ki azt a telep\u00edt\u00e9st, amelyet hozz\u00e1 k\u00edv\u00e1nja adni a Home Assistant p\u00e9ld\u00e1ny\u00e1hoz." }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 925e9111c1a..63e8421ed0e 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -22,6 +22,7 @@ from homeassistant.util import Throttle ALL_IMAGES = [ "default", + "generic-x86-64", "intel-nuc", "odroid-c2", "odroid-n2", diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index ddfbb9f20dd..dc20ea3edbc 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -1,6 +1,7 @@ """Support for the Italian train system using ViaggiaTreno API.""" import asyncio import logging +import time import aiohttp import async_timeout @@ -17,7 +18,7 @@ ATTRIBUTION = "Powered by ViaggiaTreno Data" VIAGGIATRENO_ENDPOINT = ( "http://www.viaggiatreno.it/viaggiatrenonew/" "resteasy/viaggiatreno/andamentoTreno/" - "{station_id}/{train_id}" + "{station_id}/{train_id}/{timestamp}" ) REQUEST_TIMEOUT = 5 # seconds @@ -94,7 +95,7 @@ class ViaggiaTrenoSensor(SensorEntity): self._name = name self.uri = VIAGGIATRENO_ENDPOINT.format( - station_id=station_id, train_id=train_id + station_id=station_id, train_id=train_id, timestamp=int(time.time()) * 1000 ) @property diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index f3ffd7e1db6..5d5c5548be1 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,6 +1,11 @@ """The ViCare integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import enum import logging +from typing import Generic, TypeVar from PyViCare.PyViCareDevice import Device from PyViCare.PyViCareFuelCell import FuelCell @@ -33,6 +38,16 @@ CONF_HEATING_TYPE = "heating_type" DEFAULT_HEATING_TYPE = "generic" +ApiT = TypeVar("ApiT", bound=Device) + + +@dataclass() +class ViCareRequiredKeysMixin(Generic[ApiT]): + """Mixin for required keys.""" + + value_getter: Callable[[ApiT], bool] + + class HeatingType(enum.Enum): """Possible options for heating type.""" diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 0c98d22e9ae..88d6e3ac06a 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,28 +1,35 @@ """Viessmann ViCare sensor device.""" +from __future__ import annotations + from contextlib import suppress +from dataclasses import dataclass import logging +from typing import Union from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError +from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareGazBoiler import GazBoiler +from PyViCare.PyViCareHeatPump import HeatPump import requests from homeassistant.components.binary_sensor import ( DEVICE_CLASS_POWER, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from . import ( DOMAIN as VICARE_DOMAIN, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, + ApiT, HeatingType, + ViCareRequiredKeysMixin, ) _LOGGER = logging.getLogger(__name__) -CONF_GETTER = "getter" - SENSOR_CIRCULATION_PUMP_ACTIVE = "circulationpump_active" # gas sensors @@ -31,33 +38,46 @@ SENSOR_BURNER_ACTIVE = "burner_active" # heatpump sensors SENSOR_COMPRESSOR_ACTIVE = "compressor_active" -SENSOR_TYPES = { - SENSOR_CIRCULATION_PUMP_ACTIVE: { - CONF_NAME: "Circulation pump active", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getCirculationPumpActive(), - }, - # gas sensors - SENSOR_BURNER_ACTIVE: { - CONF_NAME: "Burner active", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getBurnerActive(), - }, - # heatpump sensors - SENSOR_COMPRESSOR_ACTIVE: { - CONF_NAME: "Compressor active", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getCompressorActive(), - }, -} + +@dataclass +class ViCareBinarySensorEntityDescription( + BinarySensorEntityDescription, ViCareRequiredKeysMixin[ApiT] +): + """Describes ViCare binary sensor entity.""" + + +SENSOR_TYPES_GENERIC: tuple[ViCareBinarySensorEntityDescription[Device]] = ( + ViCareBinarySensorEntityDescription[Device]( + key=SENSOR_CIRCULATION_PUMP_ACTIVE, + name="Circulation pump active", + device_class=DEVICE_CLASS_POWER, + value_getter=lambda api: api.getCirculationPumpActive(), + ), +) + +SENSOR_TYPES_GAS: tuple[ViCareBinarySensorEntityDescription[GazBoiler]] = ( + ViCareBinarySensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_ACTIVE, + name="Burner active", + device_class=DEVICE_CLASS_POWER, + value_getter=lambda api: api.getBurnerActive(), + ), +) + +SENSOR_TYPES_HEATPUMP: tuple[ViCareBinarySensorEntityDescription[HeatPump]] = ( + ViCareBinarySensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_ACTIVE, + name="Compressor active", + device_class=DEVICE_CLASS_POWER, + value_getter=lambda api: api.getCompressorActive(), + ), +) SENSORS_GENERIC = [SENSOR_CIRCULATION_PUMP_ACTIVE] SENSORS_BY_HEATINGTYPE = { HeatingType.gas: [SENSOR_BURNER_ACTIVE], - HeatingType.heatpump: [ - SENSOR_COMPRESSOR_ACTIVE, - ], + HeatingType.heatpump: [SENSOR_COMPRESSOR_ACTIVE], HeatingType.fuelcell: [SENSOR_BURNER_ACTIVE], } @@ -78,22 +98,35 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities( [ ViCareBinarySensor( - hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, sensor + hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, description ) - for sensor in sensors + for description in ( + *SENSOR_TYPES_GENERIC, + *SENSOR_TYPES_GAS, + *SENSOR_TYPES_HEATPUMP, + ) + if description.key in sensors ] ) +DescriptionT = Union[ + ViCareBinarySensorEntityDescription[Device], + ViCareBinarySensorEntityDescription[GazBoiler], + ViCareBinarySensorEntityDescription[HeatPump], +] + + class ViCareBinarySensor(BinarySensorEntity): """Representation of a ViCare sensor.""" - def __init__(self, name, api, sensor_type): + entity_description: DescriptionT + + def __init__(self, name, api, description: DescriptionT): """Initialize the sensor.""" - self._sensor = SENSOR_TYPES[sensor_type] - self._name = f"{name} {self._sensor[CONF_NAME]}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" self._api = api - self._sensor_type = sensor_type self._state = None @property @@ -104,28 +137,18 @@ class ViCareBinarySensor(BinarySensorEntity): @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.service.id}-{self._sensor_type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + return f"{self._api.service.id}-{self.entity_description.key}" @property def is_on(self): """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 self._sensor[CONF_DEVICE_CLASS] - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self._sensor[CONF_GETTER](self._api) + self._state = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index e96b3b8120a..c7e318a6c16 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1,16 +1,20 @@ """Viessmann ViCare sensor device.""" +from __future__ import annotations + from contextlib import suppress +from dataclasses import dataclass import logging +from typing import Union from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError +from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareFuelCell import FuelCell +from PyViCare.PyViCareGazBoiler import GazBoiler +from PyViCare.PyViCareHeatPump import HeatPump import requests -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -26,13 +30,13 @@ from . import ( VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, + ApiT, HeatingType, + ViCareRequiredKeysMixin, ) _LOGGER = logging.getLogger(__name__) -CONF_GETTER = "getter" - SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_OUTSIDE_TEMPERATURE = "outside_temperature" @@ -70,200 +74,212 @@ SENSOR_POWER_PRODUCTION_THIS_WEEK = "power_production_this_week" SENSOR_POWER_PRODUCTION_THIS_MONTH = "power_production_this_month" SENSOR_POWER_PRODUCTION_THIS_YEAR = "power_production_this_year" -SENSOR_TYPES = { - SENSOR_OUTSIDE_TEMPERATURE: { - CONF_NAME: "Outside Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getOutsideTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_SUPPLY_TEMPERATURE: { - CONF_NAME: "Supply Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getSupplyTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - # gas sensors - SENSOR_BOILER_TEMPERATURE: { - CONF_NAME: "Boiler Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getBoilerTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_BURNER_MODULATION: { - CONF_NAME: "Burner modulation", - CONF_ICON: "mdi:percent", - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - CONF_GETTER: lambda api: api.getBurnerModulation(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_TODAY: { - CONF_NAME: "Hot water gas consumption today", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterToday(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_THIS_WEEK: { - CONF_NAME: "Hot water gas consumption this week", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_THIS_MONTH: { - CONF_NAME: "Hot water gas consumption this month", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_DHW_GAS_CONSUMPTION_THIS_YEAR: { - CONF_NAME: "Hot water gas consumption this year", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_TODAY: { - CONF_NAME: "Heating gas consumption today", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingToday(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_THIS_WEEK: { - CONF_NAME: "Heating gas consumption this week", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingThisWeek(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_THIS_MONTH: { - CONF_NAME: "Heating gas consumption this month", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingThisMonth(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_GAS_CONSUMPTION_THIS_YEAR: { - CONF_NAME: "Heating gas consumption this year", - CONF_ICON: "mdi:power", - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getGasConsumptionHeatingThisYear(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_BURNER_STARTS: { - CONF_NAME: "Burner Starts", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: None, - CONF_GETTER: lambda api: api.getBurnerStarts(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_BURNER_HOURS: { - CONF_NAME: "Burner Hours", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getBurnerHours(), - CONF_DEVICE_CLASS: None, - }, - # heatpump sensors - SENSOR_COMPRESSOR_STARTS: { - CONF_NAME: "Compressor Starts", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: None, - CONF_GETTER: lambda api: api.getCompressorStarts(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS: { - CONF_NAME: "Compressor Hours", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHours(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS1: { - CONF_NAME: "Compressor Hours Load Class 1", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass1(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS2: { - CONF_NAME: "Compressor Hours Load Class 2", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass2(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS3: { - CONF_NAME: "Compressor Hours Load Class 3", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass3(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS4: { - CONF_NAME: "Compressor Hours Load Class 4", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass4(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_COMPRESSOR_HOURS_LOADCLASS5: { - CONF_NAME: "Compressor Hours Load Class 5", - CONF_ICON: "mdi:counter", - CONF_UNIT_OF_MEASUREMENT: TIME_HOURS, - CONF_GETTER: lambda api: api.getCompressorHoursLoadClass5(), - CONF_DEVICE_CLASS: None, - }, - SENSOR_RETURN_TEMPERATURE: { - CONF_NAME: "Return Temperature", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_GETTER: lambda api: api.getReturnTemperature(), - CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - }, - # fuelcell sensors - SENSOR_POWER_PRODUCTION_CURRENT: { - CONF_NAME: "Power production current", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: POWER_WATT, - CONF_GETTER: lambda api: api.getPowerProductionCurrent(), - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, - SENSOR_POWER_PRODUCTION_TODAY: { - CONF_NAME: "Power production today", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionToday(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - SENSOR_POWER_PRODUCTION_THIS_WEEK: { - CONF_NAME: "Power production this week", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionThisWeek(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - SENSOR_POWER_PRODUCTION_THIS_MONTH: { - CONF_NAME: "Power production this month", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionThisMonth(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - SENSOR_POWER_PRODUCTION_THIS_YEAR: { - CONF_NAME: "Power production this year", - CONF_ICON: None, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_GETTER: lambda api: api.getPowerProductionThisYear(), - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, -} + +@dataclass +class ViCareSensorEntityDescription( + SensorEntityDescription, ViCareRequiredKeysMixin[ApiT] +): + """Describes ViCare sensor entity.""" + + +SENSOR_TYPES_GENERIC: tuple[ViCareSensorEntityDescription[Device], ...] = ( + ViCareSensorEntityDescription[Device]( + key=SENSOR_OUTSIDE_TEMPERATURE, + name="Outside Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getOutsideTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), + ViCareSensorEntityDescription[Device]( + key=SENSOR_SUPPLY_TEMPERATURE, + name="Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getSupplyTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), +) + +SENSOR_TYPES_GAS: tuple[ViCareSensorEntityDescription[GazBoiler], ...] = ( + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BOILER_TEMPERATURE, + name="Boiler Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getBoilerTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_MODULATION, + name="Burner modulation", + icon="mdi:percent", + native_unit_of_measurement=PERCENTAGE, + value_getter=lambda api: api.getBurnerModulation(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_TODAY, + name="Hot water gas consumption today", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterToday(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_THIS_WEEK, + name="Hot water gas consumption this week", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_THIS_MONTH, + name="Hot water gas consumption this month", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_DHW_GAS_CONSUMPTION_THIS_YEAR, + name="Hot water gas consumption this year", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_TODAY, + name="Heating gas consumption today", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingToday(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_THIS_WEEK, + name="Heating gas consumption this week", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingThisWeek(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_THIS_MONTH, + name="Heating gas consumption this month", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingThisMonth(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_GAS_CONSUMPTION_THIS_YEAR, + name="Heating gas consumption this year", + icon="mdi:power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getGasConsumptionHeatingThisYear(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_STARTS, + name="Burner Starts", + icon="mdi:counter", + value_getter=lambda api: api.getBurnerStarts(), + ), + ViCareSensorEntityDescription[GazBoiler]( + key=SENSOR_BURNER_HOURS, + name="Burner Hours", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getBurnerHours(), + ), +) + +SENSOR_TYPES_HEATPUMP: tuple[ViCareSensorEntityDescription[HeatPump], ...] = ( + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_STARTS, + name="Compressor Starts", + icon="mdi:counter", + value_getter=lambda api: api.getCompressorStarts(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS, + name="Compressor Hours", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHours(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS1, + name="Compressor Hours Load Class 1", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass1(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS2, + name="Compressor Hours Load Class 2", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass2(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS3, + name="Compressor Hours Load Class 3", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass3(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS4, + name="Compressor Hours Load Class 4", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass4(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_COMPRESSOR_HOURS_LOADCLASS5, + name="Compressor Hours Load Class 5", + icon="mdi:counter", + native_unit_of_measurement=TIME_HOURS, + value_getter=lambda api: api.getCompressorHoursLoadClass5(), + ), + ViCareSensorEntityDescription[HeatPump]( + key=SENSOR_RETURN_TEMPERATURE, + name="Return Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getReturnTemperature(), + device_class=DEVICE_CLASS_TEMPERATURE, + ), +) + +SENSOR_TYPES_FUELCELL: tuple[ViCareSensorEntityDescription[FuelCell], ...] = ( + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_CURRENT, + name="Power production current", + native_unit_of_measurement=POWER_WATT, + value_getter=lambda api: api.getPowerProductionCurrent(), + device_class=DEVICE_CLASS_POWER, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_TODAY, + name="Power production today", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionToday(), + device_class=DEVICE_CLASS_ENERGY, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_THIS_WEEK, + name="Power production this week", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionThisWeek(), + device_class=DEVICE_CLASS_ENERGY, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_THIS_MONTH, + name="Power production this month", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionThisMonth(), + device_class=DEVICE_CLASS_ENERGY, + ), + ViCareSensorEntityDescription[FuelCell]( + key=SENSOR_POWER_PRODUCTION_THIS_YEAR, + name="Power production this year", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerProductionThisYear(), + device_class=DEVICE_CLASS_ENERGY, + ), +) SENSORS_GENERIC = [SENSOR_OUTSIDE_TEMPERATURE, SENSOR_SUPPLY_TEMPERATURE] @@ -331,21 +347,36 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities( [ - ViCareSensor(hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, sensor) - for sensor in sensors + ViCareSensor(hass.data[VICARE_DOMAIN][VICARE_NAME], vicare_api, description) + for description in ( + *SENSOR_TYPES_GENERIC, + *SENSOR_TYPES_GAS, + *SENSOR_TYPES_HEATPUMP, + *SENSOR_TYPES_FUELCELL, + ) + if description.key in sensors ] ) +DescriptionT = Union[ + ViCareSensorEntityDescription[Device], + ViCareSensorEntityDescription[GazBoiler], + ViCareSensorEntityDescription[HeatPump], + ViCareSensorEntityDescription[FuelCell], +] + + class ViCareSensor(SensorEntity): """Representation of a ViCare sensor.""" - def __init__(self, name, api, sensor_type): + entity_description: DescriptionT + + def __init__(self, name, api, description: DescriptionT): """Initialize the sensor.""" - self._sensor = SENSOR_TYPES[sensor_type] - self._name = f"{name} {self._sensor[CONF_NAME]}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" self._api = api - self._sensor_type = sensor_type self._state = None @property @@ -356,38 +387,18 @@ class ViCareSensor(SensorEntity): @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.service.id}-{self._sensor_type}" - - @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._sensor[CONF_ICON] + return f"{self._api.service.id}-{self.entity_description.key}" @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._sensor[CONF_UNIT_OF_MEASUREMENT] - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._sensor[CONF_DEVICE_CLASS] - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self._sensor[CONF_GETTER](self._api) + self._state = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py index d47e738a858..36ecab0ca48 100644 --- a/homeassistant/components/vilfo/const.py +++ b/homeassistant/components/vilfo/const.py @@ -1,19 +1,16 @@ """Constants for the Vilfo Router integration.""" -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - DEVICE_CLASS_TIMESTAMP, - PERCENTAGE, -) +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE DOMAIN = "vilfo" -ATTR_API_DATA_FIELD = "api_data_field" ATTR_API_DATA_FIELD_LOAD = "load" ATTR_API_DATA_FIELD_BOOT_TIME = "boot_time" -ATTR_LABEL = "label" ATTR_LOAD = "load" -ATTR_UNIT = "unit" ATTR_BOOT_TIME = "boot_time" ROUTER_DEFAULT_HOST = "admin.vilfo.com" @@ -21,17 +18,32 @@ ROUTER_DEFAULT_MODEL = "Vilfo Router" ROUTER_DEFAULT_NAME = "Vilfo Router" ROUTER_MANUFACTURER = "Vilfo AB" -SENSOR_TYPES = { - ATTR_LOAD: { - ATTR_LABEL: "Load", - ATTR_UNIT: PERCENTAGE, - ATTR_ICON: "mdi:memory", - ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD, - }, - ATTR_BOOT_TIME: { - ATTR_LABEL: "Boot time", - ATTR_ICON: "mdi:timer-outline", - ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_BOOT_TIME, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, -} + +@dataclass +class VilfoRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): + """Describes Vilfo sensor entity.""" + + +SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( + VilfoSensorEntityDescription( + key=ATTR_LOAD, + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + api_key=ATTR_API_DATA_FIELD_LOAD, + ), + VilfoSensorEntityDescription( + key=ATTR_BOOT_TIME, + name="Boot time", + icon="mdi:timer-outline", + api_key=ATTR_API_DATA_FIELD_BOOT_TIME, + device_class=DEVICE_CLASS_TIMESTAMP, + ), +) diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index bb2df21f257..463ed31650c 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -1,17 +1,13 @@ """Support for Vilfo Router sensors.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_ICON from .const import ( - ATTR_API_DATA_FIELD, - ATTR_DEVICE_CLASS, - ATTR_LABEL, - ATTR_UNIT, DOMAIN, ROUTER_DEFAULT_MODEL, ROUTER_DEFAULT_NAME, ROUTER_MANUFACTURER, SENSOR_TYPES, + VilfoSensorEntityDescription, ) @@ -19,21 +15,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Add Vilfo Router entities from a config_entry.""" vilfo = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] + entities = [VilfoRouterSensor(vilfo, description) for description in SENSOR_TYPES] - for sensor_type in SENSOR_TYPES: - sensors.append(VilfoRouterSensor(sensor_type, vilfo)) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class VilfoRouterSensor(SensorEntity): """Define a Vilfo Router Sensor.""" - def __init__(self, sensor_type, api): + entity_description: VilfoSensorEntityDescription + + def __init__(self, api, description: VilfoSensorEntityDescription): """Initialize.""" + self.entity_description = description self.api = api - self.sensor_type = sensor_type self._device_info = { "identifiers": {(DOMAIN, api.host, api.mac_address)}, "name": ROUTER_DEFAULT_NAME, @@ -41,8 +36,7 @@ class VilfoRouterSensor(SensorEntity): "model": ROUTER_DEFAULT_MODEL, "sw_version": api.firmware_version, } - self._unique_id = f"{self.api.unique_id}_{self.sensor_type}" - self._state = None + self._attr_unique_id = f"{api.unique_id}_{description.key}" @property def available(self): @@ -54,41 +48,13 @@ class VilfoRouterSensor(SensorEntity): """Return the device info.""" return self._device_info - @property - def device_class(self): - """Return the device class.""" - return SENSOR_TYPES[self.sensor_type].get(ATTR_DEVICE_CLASS) - - @property - def icon(self): - """Return the icon for the sensor.""" - return SENSOR_TYPES[self.sensor_type][ATTR_ICON] - @property def name(self): """Return the name of the sensor.""" parent_device_name = self._device_info["name"] - sensor_name = SENSOR_TYPES[self.sensor_type][ATTR_LABEL] - return f"{parent_device_name} {sensor_name}" - - @property - def native_value(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return SENSOR_TYPES[self.sensor_type].get(ATTR_UNIT) + return f"{parent_device_name} {self.entity_description.name}" async def async_update(self): """Update the router data.""" await self.api.async_update() - self._state = self.api.data.get( - SENSOR_TYPES[self.sensor_type][ATTR_API_DATA_FIELD] - ) + self._attr_native_value = self.api.data.get(self.entity_description.api_key) diff --git a/homeassistant/components/vilfo/translations/fr.json b/homeassistant/components/vilfo/translations/fr.json index b6790d98d39..bfd455fe379 100644 --- a/homeassistant/components/vilfo/translations/fr.json +++ b/homeassistant/components/vilfo/translations/fr.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "Ce routeur Vilfo est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de connexion. Veuillez v\u00e9rifier les informations que vous avez fournies et r\u00e9essayer.", - "invalid_auth": "Authentification non valide. Veuillez v\u00e9rifier le jeton d'acc\u00e8s et r\u00e9essayer.", - "unknown": "Une erreur inattendue s'est produite lors de la configuration de l'int\u00e9gration." + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" }, "step": { "user": { "data": { - "access_token": "Jeton d'Acc\u00e8s", - "host": "Nom d'h\u00f4te ou adresse IP" + "access_token": "Jeton d'acc\u00e8s", + "host": "H\u00f4te" }, "description": "Configurez l'int\u00e9gration du routeur Vilfo. Vous avez besoin du nom d'h\u00f4te / IP de votre routeur Vilfo et d'un jeton d'acc\u00e8s API. Pour plus d'informations sur cette int\u00e9gration et comment obtenir ces d\u00e9tails, visitez: https://www.home-assistant.io/integrations/vilfo", "title": "Connectez-vous au routeur Vilfo" diff --git a/homeassistant/components/vilfo/translations/hu.json b/homeassistant/components/vilfo/translations/hu.json index 4e2ab47a476..157a6cdbabd 100644 --- a/homeassistant/components/vilfo/translations/hu.json +++ b/homeassistant/components/vilfo/translations/hu.json @@ -12,7 +12,7 @@ "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a Vilfo Router integr\u00e1ci\u00f3t. Sz\u00fcks\u00e9ge van a Vilfo Router gazdag\u00e9pnev\u00e9re/IP -c\u00edm\u00e9re \u00e9s egy API hozz\u00e1f\u00e9r\u00e9si jogkivonatra. Ha tov\u00e1bbi inform\u00e1ci\u00f3ra van sz\u00fcks\u00e9ge az integr\u00e1ci\u00f3r\u00f3l \u00e9s a r\u00e9szletekr\u0151l, l\u00e1togasson el a k\u00f6vetkez\u0151 webhelyre: https://www.home-assistant.io/integrations/vilfo", "title": "Csatlakoz\u00e1s a Vilfo routerhez" diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 05caae0ec08..c60ae4582ad 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -33,7 +33,6 @@ 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.update_coordinator import DataUpdateCoordinator @@ -142,16 +141,9 @@ class VizioDevice(MediaPlayerEntity): self._config_entry = config_entry self._apps_coordinator = apps_coordinator - self._name = name - self._state = None - self._volume_level = None self._volume_step = config_entry.options[CONF_VOLUME_STEP] - self._is_volume_muted = None self._current_input = None - self._current_app = None self._current_app_config = None - self._current_sound_mode = None - self._available_sound_modes = [] self._available_inputs = [] self._available_apps = [] self._all_apps = apps_coordinator.data if apps_coordinator else None @@ -159,14 +151,19 @@ class VizioDevice(MediaPlayerEntity): self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( CONF_ADDITIONAL_CONFIGS, [] ) - self._device_class = device_class - self._supported_commands = SUPPORTED_COMMANDS[device_class] self._device = device self._max_volume = float(self._device.get_max_volume()) - self._icon = ICON[device_class] - self._available = True - self._model = None - self._sw_version = None + + # Entity class attributes that will change with each update (we only include + # the ones that are initialized differently from the defaults) + self._attr_sound_mode_list = [] + self._attr_supported_features = SUPPORTED_COMMANDS[device_class] + + # Entity class attributes that will not change + self._attr_name = name + self._attr_icon = ICON[device_class] + self._attr_unique_id = self._config_entry.unique_id + self._attr_device_class = device_class def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" @@ -183,64 +180,67 @@ class VizioDevice(MediaPlayerEntity): is_on = await self._device.get_power_state(log_api_exception=False) if is_on is None: - if self._available: + if self._attr_available: _LOGGER.warning( "Lost connection to %s", self._config_entry.data[CONF_HOST] ) - self._available = False + self._attr_available = False return - if not self._available: + if not self._attr_available: _LOGGER.info( "Restored connection to %s", self._config_entry.data[CONF_HOST] ) - self._available = True + self._attr_available = True - if not self._model: - self._model = await self._device.get_model_name(log_api_exception=False) - - if not self._sw_version: - self._sw_version = await self._device.get_version(log_api_exception=False) + if not self._attr_device_info: + self._attr_device_info = { + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "name": self._attr_name, + "manufacturer": "VIZIO", + "model": await self._device.get_model_name(log_api_exception=False), + "sw_version": await self._device.get_version(log_api_exception=False), + } if not is_on: - self._state = STATE_OFF - self._volume_level = None - self._is_volume_muted = None + self._attr_state = STATE_OFF + self._attr_volume_level = None + self._attr_is_volume_muted = None self._current_input = None - self._current_app = None + self._attr_app_name = None self._current_app_config = None - self._current_sound_mode = None + self._attr_sound_mode = None return - self._state = STATE_ON + self._attr_state = STATE_ON audio_settings = await self._device.get_all_settings( VIZIO_AUDIO_SETTINGS, log_api_exception=False ) if audio_settings: - self._volume_level = float(audio_settings[VIZIO_VOLUME]) / self._max_volume + self._attr_volume_level = ( + float(audio_settings[VIZIO_VOLUME]) / self._max_volume + ) if VIZIO_MUTE in audio_settings: - self._is_volume_muted = ( + self._attr_is_volume_muted = ( audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON ) else: - self._is_volume_muted = None + self._attr_is_volume_muted = None if VIZIO_SOUND_MODE in audio_settings: - self._supported_commands |= SUPPORT_SELECT_SOUND_MODE - self._current_sound_mode = audio_settings[VIZIO_SOUND_MODE] - if not self._available_sound_modes: - self._available_sound_modes = ( - await self._device.get_setting_options( - VIZIO_AUDIO_SETTINGS, - VIZIO_SOUND_MODE, - log_api_exception=False, - ) + self._attr_supported_features |= SUPPORT_SELECT_SOUND_MODE + self._attr_sound_mode = audio_settings[VIZIO_SOUND_MODE] + if not self._attr_sound_mode_list: + self._attr_sound_mode_list = await self._device.get_setting_options( + VIZIO_AUDIO_SETTINGS, + VIZIO_SOUND_MODE, + log_api_exception=False, ) else: # Explicitly remove SUPPORT_SELECT_SOUND_MODE from supported features - self._supported_commands &= ~SUPPORT_SELECT_SOUND_MODE + self._attr_supported_features &= ~SUPPORT_SELECT_SOUND_MODE input_ = await self._device.get_current_input(log_api_exception=False) if input_: @@ -255,7 +255,7 @@ class VizioDevice(MediaPlayerEntity): self._available_inputs = [input_.name for input_ in inputs] # Return before setting app variables if INPUT_APPS isn't in available inputs - if self._device_class == DEVICE_CLASS_SPEAKER or not any( + if self._attr_device_class == DEVICE_CLASS_SPEAKER or not any( app for app in INPUT_APPS if app in self._available_inputs ): return @@ -268,13 +268,13 @@ class VizioDevice(MediaPlayerEntity): log_api_exception=False ) - self._current_app = find_app_name( + self._attr_app_name = find_app_name( self._current_app_config, [APP_HOME, *self._all_apps, *self._additional_app_configs], ) - if self._current_app == NO_APP_RUNNING: - self._current_app = None + if self._attr_app_name == NO_APP_RUNNING: + self._attr_app_name = None def _get_additional_app_names(self) -> list[dict[str, Any]]: """Return list of additional apps that were included in configuration.yaml.""" @@ -331,46 +331,16 @@ class VizioDevice(MediaPlayerEntity): self._all_apps = self._apps_coordinator.data self.async_write_ha_state() - if self._device_class == DEVICE_CLASS_TV: + if self._attr_device_class == DEVICE_CLASS_TV: self.async_on_remove( self._apps_coordinator.async_add_listener(apps_list_update) ) - @property - def available(self) -> bool: - """Return the availabiliity of the device.""" - return self._available - - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def icon(self) -> str: - """Return the icon of the device.""" - return self._icon - - @property - def volume_level(self) -> float | None: - """Return the volume level of the device.""" - return self._volume_level - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._is_volume_muted - @property def source(self) -> str | None: """Return current input of the device.""" - if self._current_app is not None and self._current_input in INPUT_APPS: - return self._current_app + if self._attr_app_name is not None and self._current_input in INPUT_APPS: + return self._attr_app_name return self._current_input @@ -378,7 +348,7 @@ class VizioDevice(MediaPlayerEntity): def source_list(self) -> list[str]: """Return list of available inputs of the device.""" # If Smartcast app is in input list, and the app list has been retrieved, - # show the combination with , otherwise just return inputs + # show the combination with, otherwise just return inputs if self._available_apps: return [ *( @@ -408,50 +378,9 @@ class VizioDevice(MediaPlayerEntity): return None - @property - def app_name(self) -> str | None: - """Return the friendly name of the current app.""" - return self._current_app - - @property - def supported_features(self) -> int: - """Flag device features that are supported.""" - return self._supported_commands - - @property - def unique_id(self) -> str: - """Return the unique id of the device.""" - return self._config_entry.unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information.""" - return { - "identifiers": {(DOMAIN, self._config_entry.unique_id)}, - "name": self.name, - "manufacturer": "VIZIO", - "model": self._model, - "sw_version": self._sw_version, - } - - @property - def device_class(self) -> str: - """Return device class for entity.""" - return self._device_class - - @property - def sound_mode(self) -> str | None: - """Name of the current sound mode.""" - return self._current_sound_mode - - @property - def sound_mode_list(self) -> list[str] | None: - """List of available sound modes.""" - return self._available_sound_modes - async def async_select_sound_mode(self, sound_mode): """Select sound mode.""" - if sound_mode in self._available_sound_modes: + if sound_mode in self._attr_sound_mode_list: await self._device.set_setting( VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, @@ -471,10 +400,10 @@ class VizioDevice(MediaPlayerEntity): """Mute the volume.""" if mute: await self._device.mute_on(log_api_exception=False) - self._is_volume_muted = True + self._attr_is_volume_muted = True else: await self._device.mute_off(log_api_exception=False) - self._is_volume_muted = False + self._attr_is_volume_muted = False async def async_media_previous_track(self) -> None: """Send previous channel command.""" @@ -506,29 +435,29 @@ class VizioDevice(MediaPlayerEntity): """Increase volume of the device.""" await self._device.vol_up(num=self._volume_step, log_api_exception=False) - if self._volume_level is not None: - self._volume_level = min( - 1.0, self._volume_level + self._volume_step / self._max_volume + if self._attr_volume_level is not None: + self._attr_volume_level = min( + 1.0, self._attr_volume_level + self._volume_step / self._max_volume ) async def async_volume_down(self) -> None: """Decrease volume of the device.""" await self._device.vol_down(num=self._volume_step, log_api_exception=False) - if self._volume_level is not None: - self._volume_level = max( - 0.0, self._volume_level - self._volume_step / self._max_volume + if self._attr_volume_level is not None: + self._attr_volume_level = max( + 0.0, self._attr_volume_level - self._volume_step / self._max_volume ) async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" - if self._volume_level is not None: - if volume > self._volume_level: - num = int(self._max_volume * (volume - self._volume_level)) + if self._attr_volume_level is not None: + if volume > self._attr_volume_level: + num = int(self._max_volume * (volume - self._attr_volume_level)) await self._device.vol_up(num=num, log_api_exception=False) - self._volume_level = volume + self._attr_volume_level = volume - elif volume < self._volume_level: - num = int(self._max_volume * (self._volume_level - volume)) + elif volume < self._attr_volume_level: + num = int(self._max_volume * (self._attr_volume_level - volume)) await self._device.vol_down(num=num, log_api_exception=False) - self._volume_level = volume + self._attr_volume_level = volume diff --git a/homeassistant/components/vizio/translations/fr.json b/homeassistant/components/vizio/translations/fr.json index 5fc9158c803..b7f92f11b8d 100644 --- a/homeassistant/components/vizio/translations/fr.json +++ b/homeassistant/components/vizio/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_device": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de la connexion ", + "cannot_connect": "\u00c9chec de connexion", "updated_entry": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e mais le nom et/ou les options d\u00e9finis dans la configuration ne correspondent pas \u00e0 la configuration pr\u00e9c\u00e9demment import\u00e9e, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence." }, "error": { @@ -13,7 +13,7 @@ "step": { "pair_tv": { "data": { - "pin": "PIN" + "pin": "Code PIN" }, "description": "Votre t\u00e9l\u00e9viseur devrait afficher un code. Saisissez ce code dans le formulaire, puis passez \u00e0 l'\u00e9tape suivante pour terminer le couplage.", "title": "Processus de couplage complet" diff --git a/homeassistant/components/vizio/translations/hu.json b/homeassistant/components/vizio/translations/hu.json index edc91cdb31c..bb619e359c0 100644 --- a/homeassistant/components/vizio/translations/hu.json +++ b/homeassistant/components/vizio/translations/hu.json @@ -19,18 +19,18 @@ "title": "V\u00e9gezze el a p\u00e1ros\u00edt\u00e1si folyamatot" }, "pairing_complete": { - "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant-hoz.", + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik Home Assistanthoz.", "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "pairing_complete_import": { - "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant szolg\u00e1ltat\u00e1shoz. \n\n A Hozz\u00e1f\u00e9r\u00e9si token a \u201e** {access_token} **\u201d.", + "description": "A VIZIO SmartCast Eszk\u00f6z mostant\u00f3l csatlakozik a Home Assistant szolg\u00e1ltat\u00e1shoz. \n\nA Hozz\u00e1f\u00e9r\u00e9si token a `**{access_token}**`.", "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "device_class": "Eszk\u00f6zt\u00edpus", - "host": "Hoszt", + "host": "C\u00edm", "name": "N\u00e9v" }, "description": "A Hozz\u00e1f\u00e9r\u00e9si token csak t\u00e9v\u00e9khez sz\u00fcks\u00e9ges. Ha TV -t konfigur\u00e1l, \u00e9s m\u00e9g nincs Hozz\u00e1f\u00e9r\u00e9si token , hagyja \u00fcresen a p\u00e1ros\u00edt\u00e1si folyamathoz.", diff --git a/homeassistant/components/volumio/translations/hu.json b/homeassistant/components/volumio/translations/hu.json index e58f0666039..b504275e03f 100644 --- a/homeassistant/components/volumio/translations/hu.json +++ b/homeassistant/components/volumio/translations/hu.json @@ -10,12 +10,12 @@ }, "step": { "discovery_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a Volumio (`{name}`)-t a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a Volumio (`{name}`)-t a Home Assistanthoz?", "title": "Felfedezett Volumio" }, "user": { "data": { - "host": "Hoszt", + "host": "C\u00edm", "port": "Port" } } diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 01506d4f47e..f4a9055b54c 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -1,9 +1,15 @@ """Support for monitoring the state of Vultr Subscriptions.""" +from __future__ import annotations + import logging 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, DATA_GIGABYTES import homeassistant.helpers.config_validation as cv @@ -17,22 +23,29 @@ from . import ( _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Vultr {} {}" -MONITORED_CONDITIONS = { - ATTR_CURRENT_BANDWIDTH_USED: [ - "Current Bandwidth Used", - DATA_GIGABYTES, - "mdi:chart-histogram", - ], - ATTR_PENDING_CHARGES: ["Pending Charges", "US$", "mdi:currency-usd"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ATTR_CURRENT_BANDWIDTH_USED, + name="Current Bandwidth Used", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:chart-histogram", + ), + SensorEntityDescription( + key=ATTR_PENDING_CHARGES, + name="Pending Charges", + native_unit_of_measurement="US$", + icon="mdi:currency-usd", + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_SUBSCRIPTION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] + ), } ) @@ -41,68 +54,55 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Vultr subscription (server) sensor.""" vultr = hass.data[DATA_VULTR] - subscription = config.get(CONF_SUBSCRIPTION) - name = config.get(CONF_NAME) - monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + subscription = config[CONF_SUBSCRIPTION] + name = config[CONF_NAME] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] if subscription not in vultr.data: _LOGGER.error("Subscription %s not found", subscription) return - sensors = [] + entities = [ + VultrSensor(vultr, subscription, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - for condition in monitored_conditions: - sensors.append(VultrSensor(vultr, subscription, condition, name)) - - add_entities(sensors, True) + add_entities(entities, True) class VultrSensor(SensorEntity): """Representation of a Vultr subscription sensor.""" - def __init__(self, vultr, subscription, condition, name): + def __init__(self, vultr, subscription, name, description: SensorEntityDescription): """Initialize a new Vultr sensor.""" + self.entity_description = description self._vultr = vultr - self._condition = condition self._name = name self.subscription = subscription self.data = None - condition_info = MONITORED_CONDITIONS[condition] - - self._condition_name = condition_info[0] - self._units = condition_info[1] - self._icon = condition_info[2] - @property def name(self): """Return the name of the sensor.""" try: - return self._name.format(self._condition_name) + return self._name.format(self.entity_description.name) except IndexError: try: - return self._name.format(self.data["label"], self._condition_name) + return self._name.format( + self.data["label"], self.entity_description.name + ) except (KeyError, TypeError): return self._name - @property - def icon(self): - """Return the icon used in the frontend if any.""" - return self._icon - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement to present the value in.""" - return self._units - @property def native_value(self): """Return the value of this given sensor type.""" try: - return round(float(self.data.get(self._condition)), 2) + return round(float(self.data.get(self.entity_description.key)), 2) except (TypeError, ValueError): - return self.data.get(self._condition) + return self.data.get(self.entity_description.key) def update(self): """Update state of sensor.""" diff --git a/homeassistant/components/wallbox/translations/es.json b/homeassistant/components/wallbox/translations/es.json index 71e7a748955..1252e5eaca1 100644 --- a/homeassistant/components/wallbox/translations/es.json +++ b/homeassistant/components/wallbox/translations/es.json @@ -1,9 +1,19 @@ { "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": { - "station": "N\u00famero de serie de la estaci\u00f3n" + "password": "Contrase\u00f1a", + "station": "N\u00famero de serie de la estaci\u00f3n", + "username": "Usuario" } } } diff --git a/homeassistant/components/wallbox/translations/fr.json b/homeassistant/components/wallbox/translations/fr.json index 04428ef567f..05e57f9adc4 100644 --- a/homeassistant/components/wallbox/translations/fr.json +++ b/homeassistant/components/wallbox/translations/fr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", - "invalid_auth": "Authentification incorrecte", + "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/wallbox/translations/id.json b/homeassistant/components/wallbox/translations/id.json index 8fa55e63051..becbcbe817f 100644 --- a/homeassistant/components/wallbox/translations/id.json +++ b/homeassistant/components/wallbox/translations/id.json @@ -11,9 +11,11 @@ "step": { "user": { "data": { - "password": "Kata Sandi" + "password": "Kata Sandi", + "username": "Nama Pengguna" } } } - } + }, + "title": "Wallbox" } \ No newline at end of file diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py new file mode 100644 index 00000000000..8b3a83aa8d1 --- /dev/null +++ b/homeassistant/components/watttime/__init__.py @@ -0,0 +1,76 @@ +"""The WattTime integration.""" +from __future__ import annotations + +from datetime import timedelta + +from aiowatttime import Client +from aiowatttime.emissions import RealTimeEmissionsResponseType +from aiowatttime.errors import WattTimeError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_COORDINATOR, DOMAIN, LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) + +PLATFORMS: list[str] = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WattTime from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + + session = aiohttp_client.async_get_clientsession(hass) + + try: + client = await Client.async_login( + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session + ) + except WattTimeError as err: + LOGGER.error("Error while authenticating with WattTime: %s", err) + return False + + async def async_update_data() -> RealTimeEmissionsResponseType: + """Get the latest realtime emissions data.""" + try: + return await client.emissions.async_get_realtime_emissions( + entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE] + ) + except WattTimeError as err: + raise UpdateFailed( + f"Error while requesting data from WattTime: {err}" + ) from err + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=entry.title, + update_interval=DEFAULT_UPDATE_INTERVAL, + update_method=async_update_data, + ) + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = 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/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py new file mode 100644 index 00000000000..6c523f64331 --- /dev/null +++ b/homeassistant/components/watttime/config_flow.py @@ -0,0 +1,163 @@ +"""Config flow for WattTime integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from aiowatttime import Client +from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DOMAIN, + LOGGER, +) + +CONF_LOCATION_TYPE = "location_type" + +LOCATION_TYPE_COORDINATES = "Specify coordinates" +LOCATION_TYPE_HOME = "Use home location" + +STEP_COORDINATES_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } +) + +STEP_LOCATION_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION_TYPE): vol.In( + [LOCATION_TYPE_HOME, LOCATION_TYPE_COORDINATES] + ), + } +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WattTime.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._client: Client | None = None + self._password: str | None = None + self._username: str | None = None + + async def async_step_coordinates( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the coordinates step.""" + if not user_input: + return self.async_show_form( + step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA + ) + + if TYPE_CHECKING: + assert self._client + + unique_id = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + try: + grid_region = await self._client.emissions.async_get_grid_region( + user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE] + ) + except CoordinatesNotFoundError: + return self.async_show_form( + step_id="coordinates", + data_schema=STEP_COORDINATES_DATA_SCHEMA, + errors={CONF_LATITUDE: "unknown_coordinates"}, + ) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception while getting region: %s", err) + return self.async_show_form( + step_id="coordinates", + data_schema=STEP_COORDINATES_DATA_SCHEMA, + errors={"base": "unknown"}, + ) + + return self.async_create_entry( + title=unique_id, + data={ + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + CONF_BALANCING_AUTHORITY: grid_region["name"], + CONF_BALANCING_AUTHORITY_ABBREV: grid_region["abbrev"], + }, + ) + + async def async_step_location( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the "pick a location" step.""" + if not user_input: + return self.async_show_form( + step_id="location", data_schema=STEP_LOCATION_DATA_SCHEMA + ) + + if user_input[CONF_LOCATION_TYPE] == LOCATION_TYPE_HOME: + return await self.async_step_coordinates( + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + ) + return await self.async_step_coordinates() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if not user_input: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + self._client = await Client.async_login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=session, + ) + except InvalidCredentialsError: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={CONF_USERNAME: "invalid_auth"}, + ) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception while logging in: %s", err) + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "unknown"}, + ) + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + return await self.async_step_location() diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py new file mode 100644 index 00000000000..680505c8d43 --- /dev/null +++ b/homeassistant/components/watttime/const.py @@ -0,0 +1,11 @@ +"""Constants for the WattTime integration.""" +import logging + +DOMAIN = "watttime" + +LOGGER = logging.getLogger(__package__) + +CONF_BALANCING_AUTHORITY = "balancing_authority" +CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation" + +DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/watttime/manifest.json b/homeassistant/components/watttime/manifest.json new file mode 100644 index 00000000000..85a32bce331 --- /dev/null +++ b/homeassistant/components/watttime/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "watttime", + "name": "WattTime", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/watttime", + "requirements": [ + "aiowatttime==0.1.1" + ], + "codeowners": [ + "@bachya" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py new file mode 100644 index 00000000000..6a6d05701c4 --- /dev/null +++ b/homeassistant/components/watttime/sensor.py @@ -0,0 +1,103 @@ +"""Support for WattTime sensors.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + MASS_POUNDS, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DATA_COORDINATOR, + DOMAIN, +) + +ATTR_BALANCING_AUTHORITY = "balancing_authority" + +DEFAULT_ATTRIBUTION = "Pickup data provided by WattTime" + +SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "moer" +SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "percent" + + +REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_REALTIME_EMISSIONS_MOER, + name="Marginal Operating Emissions Rate", + icon="mdi:blur", + native_unit_of_measurement=f"{MASS_POUNDS} CO2/MWh", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT, + name="Relative Marginal Emissions Intensity", + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up WattTime sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + async_add_entities( + [ + RealtimeEmissionsSensor(coordinator, description) + for description in REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS + if description.key in coordinator.data + ] + ) + + +class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): + """Define a realtime emissions sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + if TYPE_CHECKING: + assert coordinator.config_entry + + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, + ATTR_BALANCING_AUTHORITY: coordinator.config_entry.data[ + CONF_BALANCING_AUTHORITY + ], + ATTR_LATITUDE: coordinator.config_entry.data[ATTR_LATITUDE], + ATTR_LONGITUDE: coordinator.config_entry.data[ATTR_LONGITUDE], + } + self._attr_name = f"{description.name} ({coordinator.config_entry.data[CONF_BALANCING_AUTHORITY_ABBREV]})" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/watttime/strings.json b/homeassistant/components/watttime/strings.json new file mode 100644 index 00000000000..34dc253dcde --- /dev/null +++ b/homeassistant/components/watttime/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "coordinates": { + "description": "Input the latitude and longitude to monitor:", + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + }, + "location": { + "description": "Pick a location to monitor:", + "data": { + "location_type": "[%key:common::config_flow::data::location%]" + } + }, + "user": { + "description": "Input your username and password:", + "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%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_coordinates": "No data for latitude/longitude" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/watttime/translations/ca.json b/homeassistant/components/watttime/translations/ca.json new file mode 100644 index 00000000000..09a0360fab3 --- /dev/null +++ b/homeassistant/components/watttime/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat", + "unknown_coordinates": "No hi ha dades de latitud/longitud" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Introdueix la latitud i la longitud a monitoritzar:" + }, + "location": { + "data": { + "location_type": "Ubicaci\u00f3" + }, + "description": "Tria una ubicaci\u00f3 a monitoritzar:" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix el nom d'usuari i contrasenya:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/cs.json b/homeassistant/components/watttime/translations/cs.json new file mode 100644 index 00000000000..b4df21bd3a1 --- /dev/null +++ b/homeassistant/components/watttime/translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "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" + } + }, + "location": { + "data": { + "location_type": "Um\u00edst\u011bn\u00ed" + } + }, + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/de.json b/homeassistant/components/watttime/translations/de.json new file mode 100644 index 00000000000..c6bf9641c13 --- /dev/null +++ b/homeassistant/components/watttime/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler", + "unknown_coordinates": "Keine Daten f\u00fcr Breitengrad/L\u00e4ngengrad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + }, + "description": "Gib den zu \u00fcberwachenden Breitengrad und L\u00e4ngengrad ein:" + }, + "location": { + "data": { + "location_type": "Standort" + }, + "description": "W\u00e4hle einen Standort f\u00fcr die \u00dcberwachung:" + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Gib deinen Benutzernamen und dein Passwort ein:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/en.json b/homeassistant/components/watttime/translations/en.json new file mode 100644 index 00000000000..44ae51fae53 --- /dev/null +++ b/homeassistant/components/watttime/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "unknown_coordinates": "No data for latitude/longitude" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Input the latitude and longitude to monitor:" + }, + "location": { + "data": { + "location_type": "Location" + }, + "description": "Pick a location to monitor:" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Input your username and password:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/es.json b/homeassistant/components/watttime/translations/es.json new file mode 100644 index 00000000000..922aed60d97 --- /dev/null +++ b/homeassistant/components/watttime/translations/es.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya se ha configurado" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado", + "unknown_coordinates": "No hay datos para esa latitud/longitud" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Introduzca la latitud y longitud a monitorizar:" + }, + "location": { + "data": { + "location_type": "Ubicaci\u00f3n" + }, + "description": "Escoja una ubicaci\u00f3n para monitorizar:" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Introduzca su nombre de usuario y contrase\u00f1a:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/et.json b/homeassistant/components/watttime/translations/et.json new file mode 100644 index 00000000000..c9f47756021 --- /dev/null +++ b/homeassistant/components/watttime/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge", + "unknown_coordinates": "Laius- ja/v\u00f5i pikkuskraadi andmed puuduvad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad" + }, + "description": "Sisesta j\u00e4lgitav laius- ja pikkuskraad:" + }, + "location": { + "data": { + "location_type": "Asukoht" + }, + "description": "Vali j\u00e4lgiv asukoht:" + }, + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta oma kasutajanimi ja salas\u00f5na:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/he.json b/homeassistant/components/watttime/translations/he.json new file mode 100644 index 00000000000..bbc82f5fa86 --- /dev/null +++ b/homeassistant/components/watttime/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" + }, + "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" + } + }, + "location": { + "data": { + "location_type": "\u05de\u05d9\u05e7\u05d5\u05dd" + } + }, + "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/watttime/translations/hu.json b/homeassistant/components/watttime/translations/hu.json new file mode 100644 index 00000000000..a106416f4b9 --- /dev/null +++ b/homeassistant/components/watttime/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "unknown_coordinates": "Nincs adat a megadott sz\u00e9less\u00e9g/hossz\u00fas\u00e1g vonatkoz\u00e1s\u00e1ban" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + }, + "description": "Adja meg a sz\u00e9less\u00e9gi \u00e9s a hossz\u00fas\u00e1gi fokot a monitoroz\u00e1shoz:" + }, + "location": { + "data": { + "location_type": "Elhelyezked\u00e9s" + }, + "description": "V\u00e1lasszon egy helyet a monitoroz\u00e1shoz:" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/id.json b/homeassistant/components/watttime/translations/id.json new file mode 100644 index 00000000000..2549bd6f4ff --- /dev/null +++ b/homeassistant/components/watttime/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "coordinates": { + "data": { + "latitude": "Lintang", + "longitude": "Bujur" + }, + "description": "Masukkan lintang dan bujur untuk dipantau:" + }, + "location": { + "data": { + "location_type": "Lokasi" + }, + "description": "Pilih lokasi untuk dipantau:" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan nama pengguna dan kata sandi Anda:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/it.json b/homeassistant/components/watttime/translations/it.json new file mode 100644 index 00000000000..40f41c1d046 --- /dev/null +++ b/homeassistant/components/watttime/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto", + "unknown_coordinates": "Nessun dato per latitudine/longitudine" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitudine", + "longitude": "Logitudine" + }, + "description": "Immettere la latitudine e la longitudine da monitorare:" + }, + "location": { + "data": { + "location_type": "Posizione" + }, + "description": "Scegli una posizione da monitorare:" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci il tuo nome utente e password:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/nl.json b/homeassistant/components/watttime/translations/nl.json new file mode 100644 index 00000000000..f6776744cbb --- /dev/null +++ b/homeassistant/components/watttime/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout", + "unknown_coordinates": "Geen gegevens voor lengte-/breedtegraad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + }, + "description": "Voer de breedtegraad en de lengtegraad in die u wilt monitoren:" + }, + "location": { + "data": { + "location_type": "Locatie" + }, + "description": "Kies een locatie om te monitoren:" + }, + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer uw gebruikersnaam en wachtwoord in:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/no.json b/homeassistant/components/watttime/translations/no.json new file mode 100644 index 00000000000..a58a29ff052 --- /dev/null +++ b/homeassistant/components/watttime/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil", + "unknown_coordinates": "Ingen data for breddegrad/lengdegrad" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + }, + "description": "Skriv inn breddegrad og lengdegrad som skal overv\u00e5kes:" + }, + "location": { + "data": { + "location_type": "Plassering" + }, + "description": "Velg et sted \u00e5 overv\u00e5ke:" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Skriv inn brukernavn og passord:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/ru.json b/homeassistant/components/watttime/translations/ru.json new file mode 100644 index 00000000000..d7e67187d9d --- /dev/null +++ b/homeassistant/components/watttime/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." + }, + "error": { + "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.", + "unknown_coordinates": "\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e \u0448\u0438\u0440\u043e\u0442\u0435 \u0438 \u0434\u043e\u043b\u0433\u043e\u0442\u0435." + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0438\u0440\u043e\u0442\u0443 \u0438 \u0434\u043e\u043b\u0433\u043e\u0442\u0443 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430:" + }, + "location": { + "data": { + "location_type": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430:" + }, + "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 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/zh-Hant.json b/homeassistant/components/watttime/translations/zh-Hant.json new file mode 100644 index 00000000000..898dfc05dd7 --- /dev/null +++ b/homeassistant/components/watttime/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4", + "unknown_coordinates": "\u6c92\u6709\u7d93\u5ea6/\u7def\u5ea6\u8cc7\u6599" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + }, + "description": "\u8f38\u5165\u6240\u8981\u76e3\u63a7\u7684\u7d93\u5ea6\u8207\u7def\u5ea6\uff1a" + }, + "location": { + "data": { + "location_type": "\u5ea7\u6a19" + }, + "description": "\u9078\u64c7\u6240\u8981\u76e3\u63a7\u7684\u4f4d\u7f6e\uff1a" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\uff1a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 1b89fd5e282..554f3ecf6d8 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -3,14 +3,6 @@ from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METR DOMAIN = "waze_travel_time" -ATTR_DESTINATION = "destination" -ATTR_DURATION = "duration" -ATTR_DISTANCE = "distance" -ATTR_ORIGIN = "origin" -ATTR_ROUTE = "route" - -ATTRIBUTION = "Powered by Waze" - CONF_DESTINATION = "destination" CONF_ORIGIN = "origin" CONF_INCL_FILTER = "incl_filter" @@ -29,8 +21,6 @@ DEFAULT_AVOID_TOLL_ROADS = False DEFAULT_AVOID_SUBSCRIPTION_ROADS = False DEFAULT_AVOID_FERRIES = False -ICON = "mdi:car" - UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] REGIONS = ["US", "NA", "EU", "IL", "AU"] diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 24927ac9ae3..832ec8a12e3 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -2,7 +2,7 @@ "domain": "waze_travel_time", "name": "Waze Travel Time", "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", - "requirements": ["WazeRouteCalculator==0.12"], + "requirements": ["WazeRouteCalculator==0.13"], "codeowners": [], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 43265062998..81ee48ebd2f 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -22,16 +22,10 @@ from homeassistant.const import ( ) from homeassistant.core import Config, CoreState, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( - ATTR_DESTINATION, - ATTR_DISTANCE, - ATTR_DURATION, - ATTR_ORIGIN, - ATTR_ROUTE, - ATTRIBUTION, CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, @@ -50,7 +44,6 @@ from .const import ( DEFAULT_VEHICLE_TYPE, DOMAIN, ENTITY_ID_PATTERN, - ICON, REGIONS, UNITS, VEHICLE_TYPES, @@ -90,8 +83,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform( - hass: HomeAssistant, config: Config, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Waze travel time sensor platform.""" hass.async_create_task( @@ -166,14 +162,23 @@ async def async_setup_entry( class WazeTravelTime(SensorEntity): """Representation of a Waze travel time sensor.""" + _attr_native_unit_of_measurement = TIME_MINUTES + _attr_device_info = { + "name": "Waze", + "identifiers": {(DOMAIN, DOMAIN)}, + "entry_type": "service", + } + def __init__(self, unique_id, name, origin, destination, waze_data): """Initialize the Waze travel time sensor.""" - self._unique_id = unique_id + self._attr_unique_id = unique_id self._waze_data = waze_data - self._name = name + self._attr_name = name + self._attr_icon = "mdi:car" self._state = None self._origin_entity_id = None self._destination_entity_id = None + cmpl_re = re.compile(ENTITY_ID_PATTERN) if cmpl_re.fullmatch(origin): _LOGGER.debug("Found origin source entity %s", origin) @@ -197,12 +202,7 @@ class WazeTravelTime(SensorEntity): await self.first_update() @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" if self._waze_data.duration is not None: return round(self._waze_data.duration) @@ -210,28 +210,19 @@ class WazeTravelTime(SensorEntity): return None @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return TIME_MINUTES - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict | None: """Return the state attributes of the last update.""" if self._waze_data.duration is None: return None - res = {ATTR_ATTRIBUTION: ATTRIBUTION} - res[ATTR_DURATION] = self._waze_data.duration - res[ATTR_DISTANCE] = self._waze_data.distance - res[ATTR_ROUTE] = self._waze_data.route - res[ATTR_ORIGIN] = self._waze_data.origin - res[ATTR_DESTINATION] = self._waze_data.destination - return res + return { + ATTR_ATTRIBUTION: "Powered by Waze", + "duration": self._waze_data.duration, + "distance": self._waze_data.distance, + "route": self._waze_data.route, + "origin": self._waze_data.origin, + "destination": self._waze_data.destination, + } async def first_update(self, _=None): """Run first update and write state.""" @@ -240,7 +231,7 @@ class WazeTravelTime(SensorEntity): def update(self): """Fetch new state data for the sensor.""" - _LOGGER.debug("Fetching Route for %s", self._name) + _LOGGER.debug("Fetching Route for %s", self._attr_name) # Get origin latitude and longitude from entity_id. if self._origin_entity_id is not None: self._waze_data.origin = get_location_from_entity( @@ -263,20 +254,6 @@ class WazeTravelTime(SensorEntity): self._waze_data.update() - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return { - "name": "Waze", - "identifiers": {(DOMAIN, DOMAIN)}, - "entry_type": "service", - } - - @property - def unique_id(self) -> str: - """Return unique ID of entity.""" - return self._unique_id - class WazeTravelTimeData: """WazeTravelTime Data object.""" diff --git a/homeassistant/components/waze_travel_time/translations/fr.json b/homeassistant/components/waze_travel_time/translations/fr.json index e0039ef4b14..8336bf1f6ac 100644 --- a/homeassistant/components/waze_travel_time/translations/fr.json +++ b/homeassistant/components/waze_travel_time/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "Echec de la connection" + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/waze_travel_time/translations/id.json b/homeassistant/components/waze_travel_time/translations/id.json index 587e959fe7e..3f3cd02aaf6 100644 --- a/homeassistant/components/waze_travel_time/translations/id.json +++ b/homeassistant/components/waze_travel_time/translations/id.json @@ -10,6 +10,7 @@ "user": { "data": { "destination": "Tujuan", + "name": "Nama", "origin": "Asal", "region": "Wilayah" }, diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 8331722c397..656f12950ea 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,10 +1,9 @@ """Webhooks for Home Assistant.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging import secrets -from typing import Callable from aiohttp.web import Request, Response import voluptuous as vol @@ -131,6 +130,7 @@ class WebhookView(HomeAssistantView): async def _handle(self, request: Request, webhook_id): """Handle webhook call.""" + # pylint: disable=no-self-use _LOGGER.debug("Handling webhook %s payload for %s", request.method, webhook_id) hass = request.app["hass"] return await async_handle_webhook(hass, webhook_id, request) diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index 6bb8a61eeec..4e17c5e9e34 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -37,7 +37,7 @@ async def _handle_webhook(job, trigger_data, hass, webhook_id, request): async def async_attach_trigger(hass, config, action, automation_info): """Trigger based on incoming webhooks.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] webhook_id = config.get(CONF_WEBHOOK_ID) job = HassJob(action) hass.components.webhook.async_register( diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c451645e013..7380f15b983 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -304,9 +304,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): """Flag media player features that are supported.""" supported = SUPPORT_WEBOSTV - if (self._client.sound_output == "external_arc") or ( - self._client.sound_output == "external_speaker" - ): + if self._client.sound_output in ("external_arc", "external_speaker"): supported = supported | SUPPORT_WEBOSTV_VOLUME elif self._client.sound_output != "lineout": supported = supported | SUPPORT_WEBOSTV_VOLUME | SUPPORT_VOLUME_SET diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml index f9d56cd1921..0fb3cd1ae16 100644 --- a/homeassistant/components/webostv/services.yaml +++ b/homeassistant/components/webostv/services.yaml @@ -38,9 +38,7 @@ command: domain: media_player command: name: Command - description: >- - Endpoint of the command. Known valid endpoints are listed in - https://github.com/TheRealLink/pylgtv/blob/master/pylgtv/endpoints.py + description: Endpoint of the command. required: true example: "system.launcher/open" selector: diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 62c21ef5894..aec56fdfbf2 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Hashable -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable, Hashable +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -104,8 +104,8 @@ class ActiveConnection: self.last_id = cur_id @callback - def async_close(self) -> None: - """Close down connection.""" + def async_handle_close(self) -> None: + """Handle closing down connection.""" for unsub in self.subscriptions.values(): unsub() diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index af762cf2d46..eff82a8c71d 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index a80ff111f0d..aa6a74b27ec 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -42,6 +42,7 @@ class WebsocketAPIView(HomeAssistantView): async def get(self, request: web.Request) -> web.WebSocketResponse: """Handle an incoming websocket connection.""" + # pylint: disable=no-self-use return await WebSocketHandler(request.app["hass"], request).async_handle() @@ -230,7 +231,7 @@ class WebSocketHandler: unsub_stop() if connection is not None: - connection.async_close() + connection.async_handle_close() try: self._to_write.put_nowait(None) diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index d1a15ecec3a..654ac92df56 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -78,7 +78,9 @@ class InsightCurrentPower(InsightSensor): def native_value(self) -> StateType: """Return the current power consumption.""" return ( - convert(self.wemo.insight_params[self.entity_description.key], float, 0.0) + convert( + self.wemo.insight_params.get(self.entity_description.key), float, 0.0 + ) / 1000.0 ) @@ -98,6 +100,6 @@ class InsightTodayEnergy(InsightSensor): def native_value(self) -> StateType: """Return the current energy use today.""" miliwatts = convert( - self.wemo.insight_params[self.entity_description.key], float, 0.0 + self.wemo.insight_params.get(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 46e143902f9..a9ee2579c47 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -76,16 +76,17 @@ class WemoSwitch(WemoEntity, SwitchEntity): if isinstance(self.wemo, Insight): attr["on_latest_time"] = WemoSwitch.as_uptime( - self.wemo.insight_params["onfor"] + self.wemo.insight_params.get("onfor", 0) ) attr["on_today_time"] = WemoSwitch.as_uptime( - self.wemo.insight_params["ontoday"] + self.wemo.insight_params.get("ontoday", 0) ) attr["on_total_time"] = WemoSwitch.as_uptime( - self.wemo.insight_params["ontotal"] + self.wemo.insight_params.get("ontotal", 0) ) attr["power_threshold_w"] = ( - convert(self.wemo.insight_params["powerthreshold"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params.get("powerthreshold"), float, 0.0) + / 1000.0 ) if isinstance(self.wemo, CoffeeMaker): @@ -106,14 +107,15 @@ class WemoSwitch(WemoEntity, SwitchEntity): """Return the current power usage in W.""" if isinstance(self.wemo, Insight): return ( - convert(self.wemo.insight_params["currentpower"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params.get("currentpower"), float, 0.0) + / 1000.0 ) @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" if isinstance(self.wemo, Insight): - miliwatts = convert(self.wemo.insight_params["todaymw"], float, 0.0) + miliwatts = convert(self.wemo.insight_params.get("todaymw"), float, 0.0) return round(miliwatts / (1000.0 * 1000.0 * 60), 2) @property @@ -122,7 +124,7 @@ class WemoSwitch(WemoEntity, SwitchEntity): if isinstance(self.wemo, CoffeeMaker): return self.wemo.mode_string if isinstance(self.wemo, Insight): - standby_state = int(self.wemo.insight_params["state"]) + standby_state = int(self.wemo.insight_params.get("state", 0)) if standby_state == WEMO_ON: return STATE_ON if standby_state == WEMO_OFF: diff --git a/homeassistant/components/wemo/translations/fr.json b/homeassistant/components/wemo/translations/fr.json index e0372147f58..6b0f48e38e5 100644 --- a/homeassistant/components/wemo/translations/fr.json +++ b/homeassistant/components/wemo/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Aucun p\u00e9riph\u00e9rique Wemo trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Une seule configuration de Wemo est possible." + "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": { diff --git a/homeassistant/components/wemo/translations/hu.json b/homeassistant/components/wemo/translations/hu.json index ff9f4dc5f75..f27d566ceb9 100644 --- a/homeassistant/components/wemo/translations/hu.json +++ b/homeassistant/components/wemo/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Wemo-t?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Wemo-t?" } } }, diff --git a/homeassistant/components/wemo/translations/id.json b/homeassistant/components/wemo/translations/id.json index af0b3128cb9..831b0822f64 100644 --- a/homeassistant/components/wemo/translations/id.json +++ b/homeassistant/components/wemo/translations/id.json @@ -9,5 +9,10 @@ "description": "Ingin menyiapkan Wemo?" } } + }, + "device_automation": { + "trigger_type": { + "long_press": "Tombol Wemo ditekan selama 2 detik" + } } } \ No newline at end of file diff --git a/homeassistant/components/wemo/translations/pl.json b/homeassistant/components/wemo/translations/pl.json index d8b06f79e48..db308b52ace 100644 --- a/homeassistant/components/wemo/translations/pl.json +++ b/homeassistant/components/wemo/translations/pl.json @@ -12,7 +12,7 @@ }, "device_automation": { "trigger_type": { - "long_press": "Przycisk Wemo zosta\u0142 wci\u015bni\u0119ty przez 2 sekundy" + "long_press": "przycisk Wemo zostanie przytrzymany przez 2 sekundy" } } } \ No newline at end of file diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py new file mode 100644 index 00000000000..2c51ee07cc4 --- /dev/null +++ b/homeassistant/components/whirlpool/__init__.py @@ -0,0 +1,45 @@ +"""The Whirlpool Sixth Sense integration.""" +import logging + +import aiohttp +from whirlpool.auth import Auth + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import AUTH_INSTANCE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Whirlpool Sixth Sense from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + auth = Auth(entry.data["username"], entry.data["password"]) + try: + await auth.do_auth(store=False) + except aiohttp.ClientError as ex: + raise ConfigEntryNotReady("Cannot connect") from ex + + if not auth.is_access_token_valid(): + _LOGGER.error("Authentication failed") + return False + + hass.data[DOMAIN][entry.entry_id] = {AUTH_INSTANCE_KEY: auth} + + 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 diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py new file mode 100644 index 00000000000..811b05435bd --- /dev/null +++ b/homeassistant/components/whirlpool/climate.py @@ -0,0 +1,189 @@ +"""Platform for climate integration.""" +import asyncio +import logging + +import aiohttp +from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode +from whirlpool.auth import Auth + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_HORIZONTAL, + SWING_OFF, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from .const import AUTH_INSTANCE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +AIRCON_MODE_MAP = { + AirconMode.Cool: HVAC_MODE_COOL, + AirconMode.Heat: HVAC_MODE_HEAT, + AirconMode.Fan: HVAC_MODE_FAN_ONLY, +} + +HVAC_MODE_TO_AIRCON_MODE = {v: k for k, v in AIRCON_MODE_MAP.items()} + +AIRCON_FANSPEED_MAP = { + AirconFanSpeed.Off: FAN_OFF, + AirconFanSpeed.Auto: FAN_AUTO, + AirconFanSpeed.Low: FAN_LOW, + AirconFanSpeed.Medium: FAN_MEDIUM, + AirconFanSpeed.High: FAN_HIGH, +} + +FAN_MODE_TO_AIRCON_FANSPEED = {v: k for k, v in AIRCON_FANSPEED_MAP.items()} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + auth: Auth = hass.data[DOMAIN][config_entry.entry_id][AUTH_INSTANCE_KEY] + said_list = auth.get_said_list() + if not said_list: + _LOGGER.debug("No appliances found") + return + + # the whirlpool library needs to be updated to be able to support more + # than one device, so we use only the first one for now + aircon = AirConEntity(said_list[0], auth) + async_add_entities([aircon], True) + + +class AirConEntity(ClimateEntity): + """Representation of an air conditioner.""" + + _attr_fan_modes = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW, FAN_OFF] + _attr_hvac_modes = [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_OFF, + ] + _attr_max_temp = 30 + _attr_min_temp = 16 + _attr_supported_features = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_SWING_MODE + ) + _attr_swing_modes = [SWING_HORIZONTAL, SWING_OFF] + _attr_target_temperature_step = 1 + _attr_temperature_unit = TEMP_CELSIUS + _attr_should_poll = False + + def __init__(self, said, auth: Auth): + """Initialize the entity.""" + self._aircon = Aircon(auth, said, self.async_write_ha_state) + + self._attr_name = said + self._attr_unique_id = said + + async def async_added_to_hass(self) -> None: + """Connect aircon to the cloud.""" + await self._aircon.connect() + + try: + name = await self._aircon.fetch_name() + if name is not None: + self._attr_name = name + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.exception("Failed to get name") + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._aircon.get_online() + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._aircon.get_current_temp() + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._aircon.get_temp() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self._aircon.set_temp(kwargs.get(ATTR_TEMPERATURE)) + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._aircon.get_current_humidity() + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._aircon.get_humidity() + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self._aircon.set_humidity(humidity) + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, fan.""" + if not self._aircon.get_power_on(): + return HVAC_MODE_OFF + + mode: AirconMode = self._aircon.get_mode() + return AIRCON_MODE_MAP.get(mode, None) + + async def async_set_hvac_mode(self, hvac_mode): + """Set HVAC mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._aircon.set_power_on(False) + return + + mode = HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode) + if not mode: + _LOGGER.warning("Unexpected hvac mode: %s", hvac_mode) + return + + await self._aircon.set_mode(mode) + if not self._aircon.get_power_on(): + await self._aircon.set_power_on(True) + + @property + def fan_mode(self): + """Return the fan setting.""" + fanspeed = self._aircon.get_fanspeed() + return AIRCON_FANSPEED_MAP.get(fanspeed, FAN_OFF) + + async def async_set_fan_mode(self, fan_mode): + """Set fan mode.""" + fanspeed = FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode) + if not fanspeed: + return + await self._aircon.set_fanspeed(fanspeed) + + @property + def swing_mode(self): + """Return the swing setting.""" + return SWING_HORIZONTAL if self._aircon.get_h_louver_swing() else SWING_OFF + + async def async_set_swing_mode(self, swing_mode): + """Set new target temperature.""" + await self._aircon.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + + async def async_turn_on(self): + """Turn device on.""" + await self._aircon.set_power_on(True) + + async def async_turn_off(self): + """Turn device off.""" + await self._aircon.set_power_on(False) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py new file mode 100644 index 00000000000..c7ec37290cb --- /dev/null +++ b/homeassistant/components/whirlpool/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Whirlpool Sixth Sense integration.""" +import asyncio +import logging + +import aiohttp +import voluptuous as vol +from whirlpool.auth import Auth + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + auth = Auth(data[CONF_USERNAME], data[CONF_PASSWORD]) + try: + await auth.do_auth() + except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc: + raise CannotConnect from exc + + if not auth.is_access_token_valid(): + raise InvalidAuth + + return {"title": data[CONF_USERNAME]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Whirlpool Sixth Sense.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = 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: + await self.async_set_unique_id( + user_input[CONF_USERNAME].lower(), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + 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 + ) + + +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/whirlpool/const.py b/homeassistant/components/whirlpool/const.py new file mode 100644 index 00000000000..16ba293e3b2 --- /dev/null +++ b/homeassistant/components/whirlpool/const.py @@ -0,0 +1,4 @@ +"""Constants for the Whirlpool Sixth Sense integration.""" + +DOMAIN = "whirlpool" +AUTH_INSTANCE_KEY = "auth" diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json new file mode 100644 index 00000000000..9df10f32931 --- /dev/null +++ b/homeassistant/components/whirlpool/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "whirlpool", + "name": "Whirlpool Sixth Sense", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/whirlpool", + "requirements": [ + "whirlpool-sixth-sense==0.15.1" + ], + "codeowners": [ + "@abmantis" + ], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json new file mode 100644 index 00000000000..4925d73e4c4 --- /dev/null +++ b/homeassistant/components/whirlpool/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/ca.json b/homeassistant/components/whirlpool/translations/ca.json new file mode 100644 index 00000000000..f844476e4c6 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/cs.json b/homeassistant/components/whirlpool/translations/cs.json new file mode 100644 index 00000000000..c0841233cb7 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "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": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/de.json b/homeassistant/components/whirlpool/translations/de.json new file mode 100644 index 00000000000..57f62e0da32 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/en.json b/homeassistant/components/whirlpool/translations/en.json new file mode 100644 index 00000000000..74817db9ba7 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/es.json b/homeassistant/components/whirlpool/translations/es.json new file mode 100644 index 00000000000..d26c25c3548 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/et.json b/homeassistant/components/whirlpool/translations/et.json new file mode 100644 index 00000000000..983f599c870 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/he.json b/homeassistant/components/whirlpool/translations/he.json new file mode 100644 index 00000000000..96803e13b33 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "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": { + "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/whirlpool/translations/hu.json b/homeassistant/components/whirlpool/translations/hu.json new file mode 100644 index 00000000000..e1cc19c9c30 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/id.json b/homeassistant/components/whirlpool/translations/id.json new file mode 100644 index 00000000000..7244ccf8912 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/it.json b/homeassistant/components/whirlpool/translations/it.json new file mode 100644 index 00000000000..eb5545ca85a --- /dev/null +++ b/homeassistant/components/whirlpool/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/nl.json b/homeassistant/components/whirlpool/translations/nl.json new file mode 100644 index 00000000000..a4954b83866 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/no.json b/homeassistant/components/whirlpool/translations/no.json new file mode 100644 index 00000000000..4bcac3aada8 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/pt-BR.json b/homeassistant/components/whirlpool/translations/pt-BR.json new file mode 100644 index 00000000000..efdc82ab438 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Falha ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/ru.json b/homeassistant/components/whirlpool/translations/ru.json new file mode 100644 index 00000000000..994a287efd7 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/ru.json @@ -0,0 +1,17 @@ +{ + "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.", + "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": { + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/zh-Hant.json b/homeassistant/components/whirlpool/translations/zh-Hant.json new file mode 100644 index 00000000000..a3784595b65 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 55b13921c1c..e155a48fd72 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -222,3 +222,11 @@ class WiffiEntity(Entity): ): self._value = None self.async_write_ha_state() + + def _is_measurement_entity(self): + """Measurement entities have a value in present time.""" + return not self._name.endswith("_gestern") and not self._is_metered_entity() + + def _is_metered_entity(self): + """Metered entities have a value that keeps increasing until reset.""" + return self._name.endswith("_pro_h") or self._name.endswith("_heute") diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index b9bcd317b46..c16ae3c6aca 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -5,6 +5,8 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import DEGREE, PRESSURE_MBAR, TEMP_CELSIUS @@ -70,6 +72,12 @@ class NumberEntity(WiffiEntity, SensorEntity): metric.unit_of_measurement, metric.unit_of_measurement ) self._value = metric.value + + if self._is_measurement_entity(): + self._attr_state_class = STATE_CLASS_MEASUREMENT + elif self._is_metered_entity(): + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self.reset_expiration_date() @property @@ -97,7 +105,9 @@ class NumberEntity(WiffiEntity, SensorEntity): self._unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) + self._value = metric.value + self.async_write_ha_state() diff --git a/homeassistant/components/wiffi/translations/fr.json b/homeassistant/components/wiffi/translations/fr.json index 599ea7a4b65..3d24f7791c0 100644 --- a/homeassistant/components/wiffi/translations/fr.json +++ b/homeassistant/components/wiffi/translations/fr.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "port": "Port de serveur" + "port": "Port" }, "title": "Configurer le serveur TCP pour les appareils WIFFI" } diff --git a/homeassistant/components/wilight/translations/hu.json b/homeassistant/components/wilight/translations/hu.json index 3b769a88b8f..9ef669f1ed3 100644 --- a/homeassistant/components/wilight/translations/hu.json +++ b/homeassistant/components/wilight/translations/hu.json @@ -8,7 +8,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a WiLight {name} ? \n\n T\u00e1mogatja: {components}", + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a WiLight {name}-t ? \n\nT\u00e1mogatja: {components}", "title": "WiLight" } } diff --git a/homeassistant/components/wilight/translations/id.json b/homeassistant/components/wilight/translations/id.json index dae7b0bd16a..06616b29e35 100644 --- a/homeassistant/components/wilight/translations/id.json +++ b/homeassistant/components/wilight/translations/id.json @@ -5,7 +5,7 @@ "not_supported_device": "Perangkat WiLight ini saat ini tidak didukung.", "not_wilight_device": "Perangkat ini bukan perangkat WiLight" }, - "flow_title": "WiLight: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Apakah Anda ingin menyiapkan WiLight {name}?\n\nIni mendukung: {components}", diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index b70b8b5ca1a..9e4beff8c38 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -2,13 +2,14 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import datetime from datetime import timedelta from enum import Enum, IntEnum import logging import re -from typing import Any, Callable, Dict +from typing import Any, Dict from aiohttp.web import Response import requests @@ -507,7 +508,7 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" - return HomeAssistantView.json({"message": message, "code": message_code}, 200) + return HomeAssistantView.json({"message": message, "code": message_code}) class WebhookAvailability(IntEnum): @@ -640,6 +641,7 @@ class DataManager: Withings' API occasionally and incorrectly throws errors. Retrying the call tends to work. """ + # pylint: disable=no-self-use exception = None for attempt in range(1, attempts + 1): _LOGGER.debug("Attempt %s of %s", attempt, attempts) diff --git a/homeassistant/components/withings/translations/ca.json b/homeassistant/components/withings/translations/ca.json index b548735d426..c75e08e0234 100644 --- a/homeassistant/components/withings/translations/ca.json +++ b/homeassistant/components/withings/translations/ca.json @@ -10,7 +10,7 @@ "default": "Autenticaci\u00f3 exitosa amb Withings." }, "error": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "flow_title": "{profile}", "step": { diff --git a/homeassistant/components/withings/translations/fr.json b/homeassistant/components/withings/translations/fr.json index b5f524698f5..9f675026022 100644 --- a/homeassistant/components/withings/translations/fr.json +++ b/homeassistant/components/withings/translations/fr.json @@ -2,8 +2,8 @@ "config": { "abort": { "already_configured": "Configuration mise \u00e0 jour pour le profil.", - "authorize_url_timeout": "D\u00e9lai d'expiration g\u00e9n\u00e9rant une URL d'autorisation.", - "missing_configuration": "L'int\u00e9gration Withings n'est pas configur\u00e9e. Veuillez suivre la documentation.", + "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.", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide] ( {docs_url} )" }, "create_entry": { @@ -15,7 +15,7 @@ "flow_title": "Withings: {profile}", "step": { "pick_implementation": { - "title": "Choisissez une m\u00e9thode d'authentification" + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "profile": { "data": { @@ -26,7 +26,7 @@ }, "reauth": { "description": "Le profile \" {profile} \" doit \u00eatre r\u00e9-authentifi\u00e9 afin de continuer \u00e0 recevoir les donn\u00e9es Withings.", - "title": "R\u00e9-authentifier le profil" + "title": "R\u00e9-authentifier l'int\u00e9gration" } } } diff --git a/homeassistant/components/withings/translations/hu.json b/homeassistant/components/withings/translations/hu.json index e26cff027fc..1a64a95de5e 100644 --- a/homeassistant/components/withings/translations/hu.json +++ b/homeassistant/components/withings/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "A profil konfigur\u00e1ci\u00f3ja friss\u00edtve.", "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\u00e9rem, 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." }, "create_entry": { @@ -19,9 +19,9 @@ }, "profile": { "data": { - "profile": "Profil" + "profile": "Profil n\u00e9v" }, - "description": "Melyik profilt v\u00e1lasztottad ki a Withings weboldalon? Fontos, hogy a profilok egyeznek, k\u00fcl\u00f6nben az adatok helytelen c\u00edmk\u00e9vel lesznek ell\u00e1tva.", + "description": "K\u00e9rem, adjon meg egy egyedi profilnevet. Ez \u00e1ltal\u00e1ban az el\u0151z\u0151 l\u00e9p\u00e9sben kiv\u00e1lasztott profil neve.", "title": "Felhaszn\u00e1l\u00f3i profil." }, "reauth": { diff --git a/homeassistant/components/withings/translations/id.json b/homeassistant/components/withings/translations/id.json index e254e61d91e..eb21a0d3352 100644 --- a/homeassistant/components/withings/translations/id.json +++ b/homeassistant/components/withings/translations/id.json @@ -12,7 +12,7 @@ "error": { "already_configured": "Akun sudah dikonfigurasi" }, - "flow_title": "Withings: {profile}", + "flow_title": "{profile}", "step": { "pick_implementation": { "title": "Pilih Metode Autentikasi" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index b730ac1543a..06c1f8b5dc3 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from typing import Callable +from collections.abc import Callable from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError diff --git a/homeassistant/components/wled/translations/fr.json b/homeassistant/components/wled/translations/fr.json index dec038a8a92..03255541ad9 100644 --- a/homeassistant/components/wled/translations/fr.json +++ b/homeassistant/components/wled/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Cet appareil WLED est d\u00e9j\u00e0 configur\u00e9.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "cannot_connect": "\u00c9chec de connexion" }, "error": { @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "description": "Configurez votre WLED pour l'int\u00e9grer \u00e0 Home Assistant." }, diff --git a/homeassistant/components/wled/translations/hu.json b/homeassistant/components/wled/translations/hu.json index 769573bfc89..1fa29cfee48 100644 --- a/homeassistant/components/wled/translations/hu.json +++ b/homeassistant/components/wled/translations/hu.json @@ -7,16 +7,16 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "WLED: {name}", + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "\u00c1ll\u00edtsd be a WLED-et a Home Assistant-ba val\u00f3 integr\u00e1l\u00e1shoz." + "description": "\u00c1ll\u00edtsa be a WLED-et Home Assistantba val\u00f3 integr\u00e1l\u00e1shoz." }, "zeroconf_confirm": { - "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` WLED-et a Home Assistant-hoz?", + "description": "Szeretn\u00e9 hozz\u00e1adni a(z) `{name}` WLED-et Home Assistanthoz?", "title": "Felfedezett WLED eszk\u00f6z" } } diff --git a/homeassistant/components/wled/translations/id.json b/homeassistant/components/wled/translations/id.json index 6437dfaf83e..122cfd9da0b 100644 --- a/homeassistant/components/wled/translations/id.json +++ b/homeassistant/components/wled/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "WLED: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index f43003738df..f75e25dd97e 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.2"], + "requirements": ["holidays==0.11.3.1"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py index d1438a46f23..b6e5a89efb3 100644 --- a/homeassistant/components/xbox/browse_media.py +++ b/homeassistant/components/xbox/browse_media.py @@ -1,6 +1,8 @@ """Support for media browsing.""" from __future__ import annotations +from typing import NamedTuple + from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.const import HOME_APP_IDS, SYSTEM_PFN_ID_MAP from xbox.webapi.api.provider.catalog.models import ( @@ -23,15 +25,23 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_GAME, ) + +class MediaTypeDetails(NamedTuple): + """Details for media type.""" + + type: str + cls: str + + TYPE_MAP = { - "App": { - "type": MEDIA_TYPE_APP, - "class": MEDIA_CLASS_APP, - }, - "Game": { - "type": MEDIA_TYPE_GAME, - "class": MEDIA_CLASS_GAME, - }, + "App": MediaTypeDetails( + type=MEDIA_TYPE_APP, + cls=MEDIA_CLASS_APP, + ), + "Game": MediaTypeDetails( + type=MEDIA_TYPE_GAME, + cls=MEDIA_CLASS_GAME, + ), } @@ -109,11 +119,11 @@ async def build_item_response( BrowseMedia( media_class=MEDIA_CLASS_DIRECTORY, media_content_id=c_type, - media_content_type=TYPE_MAP[c_type]["type"], + media_content_type=TYPE_MAP[c_type].type, title=f"{c_type}s", can_play=False, can_expand=True, - children_media_class=TYPE_MAP[c_type]["class"], + children_media_class=TYPE_MAP[c_type].cls, ) ) @@ -145,7 +155,7 @@ async def build_item_response( for app in apps.result if app.content_type == media_content_id and app.one_store_product_id ], - children_media_class=TYPE_MAP[media_content_id]["class"], + children_media_class=TYPE_MAP[media_content_id].cls, ) @@ -159,9 +169,9 @@ def item_payload(item: InstalledPackage, images: dict[str, list[Image]]): thumbnail = f"https:{thumbnail}" return BrowseMedia( - media_class=TYPE_MAP[item.content_type]["class"], + media_class=TYPE_MAP[item.content_type].cls, media_content_id=item.one_store_product_id, - media_content_type=TYPE_MAP[item.content_type]["type"], + media_content_type=TYPE_MAP[item.content_type].type, title=item.name, can_play=True, can_expand=False, diff --git a/homeassistant/components/xbox/translations/hu.json b/homeassistant/components/xbox/translations/hu.json index b35b1b8e2fc..24c46bb8ab0 100644 --- a/homeassistant/components/xbox/translations/hu.json +++ b/homeassistant/components/xbox/translations/hu.json @@ -2,7 +2,7 @@ "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\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "create_entry": { diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index cad3afb11ba..3935f4fdc57 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -1,7 +1,9 @@ """Support for Xiaomi Aqara sensors.""" +from __future__ import annotations + import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_BATTERY_LEVEL, DEVICE_CLASS_BATTERY, @@ -22,14 +24,51 @@ from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "temperature": [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "humidity": [PERCENTAGE, None, DEVICE_CLASS_HUMIDITY], - "illumination": ["lm", None, DEVICE_CLASS_ILLUMINANCE], - "lux": [LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], - "pressure": [PRESSURE_HPA, None, DEVICE_CLASS_PRESSURE], - "bed_activity": ["μm", None, None], - "load_power": [POWER_WATT, None, DEVICE_CLASS_POWER], +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "temperature": SensorEntityDescription( + key="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + "humidity": SensorEntityDescription( + key="humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + "illumination": SensorEntityDescription( + key="illumination", + native_unit_of_measurement="lm", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + "lux": SensorEntityDescription( + key="lux", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + "pressure": SensorEntityDescription( + key="pressure", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + ), + "bed_activity": SensorEntityDescription( + key="bed_activity", + native_unit_of_measurement="μm", + device_class=None, + ), + "load_power": SensorEntityDescription( + key="load_power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + "final_tilt_angle": SensorEntityDescription( + key="final_tilt_angle", + ), + "coordination": SensorEntityDescription( + key="coordination", + ), + "Battery": SensorEntityDescription( + key="Battery", + ), } @@ -114,45 +153,16 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): def __init__(self, device, name, data_key, xiaomi_hub, config_entry): """Initialize the XiaomiSensor.""" self._data_key = data_key + self.entity_description = SENSOR_TYPES[data_key] super().__init__(device, name, xiaomi_hub, config_entry) - @property - def icon(self): - """Return the icon to use in the frontend.""" - try: - return SENSOR_TYPES.get(self._data_key)[1] - except TypeError: - return None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - try: - return SENSOR_TYPES.get(self._data_key)[0] - except TypeError: - return None - - @property - def device_class(self): - """Return the device class of this entity.""" - return ( - SENSOR_TYPES.get(self._data_key)[2] - if self._data_key in SENSOR_TYPES - else None - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def parse_data(self, data, raw_data): """Parse data sent by gateway.""" value = data.get(self._data_key) if value is None: return False if self._data_key in ("coordination", "status"): - self._state = value + self._attr_native_value = value return True value = float(value) if self._data_key in ("temperature", "humidity", "pressure"): @@ -166,29 +176,17 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): if self._data_key == "pressure" and value == 0: return False if self._data_key in ("illumination", "lux"): - self._state = round(value) + self._attr_native_value = round(value) else: - self._state = round(value, 1) + self._attr_native_value = round(value, 1) return True class XiaomiBatterySensor(XiaomiDevice, SensorEntity): """Representation of a XiaomiSensor.""" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return PERCENTAGE - - @property - def device_class(self): - """Return the device class of this entity.""" - return DEVICE_CLASS_BATTERY - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = DEVICE_CLASS_BATTERY def parse_data(self, data, raw_data): """Parse data sent by gateway.""" @@ -198,7 +196,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): battery_level = int(self._extra_state_attributes.pop(ATTR_BATTERY_LEVEL)) if battery_level <= 0 or battery_level > 100: return False - self._state = battery_level + self._attr_native_value = battery_level return True def parse_voltage(self, data): diff --git a/homeassistant/components/xiaomi_aqara/translations/fr.json b/homeassistant/components/xiaomi_aqara/translations/fr.json index f4c16e045b7..f6ca4af9d3e 100644 --- a/homeassistant/components/xiaomi_aqara/translations/fr.json +++ b/homeassistant/components/xiaomi_aqara/translations/fr.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9.", - "already_in_progress": "Le flux de configuration pour cette passerelle est d\u00e9j\u00e0 en cours", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "not_xiaomi_aqara": "Ce n'est pas une passerelle Xiaomi Aqara, l'appareil d\u00e9couvert ne correspond pas aux passerelles connues" }, "error": { @@ -16,7 +16,7 @@ "step": { "select": { "data": { - "select_ip": "IP de la passerelle" + "select_ip": "Adresse IP" }, "description": "Ex\u00e9cutez \u00e0 nouveau la configuration si vous souhaitez connecter des passerelles suppl\u00e9mentaires", "title": "S\u00e9lectionnez la passerelle Xiaomi Aqara que vous souhaitez connecter" diff --git a/homeassistant/components/xiaomi_aqara/translations/he.json b/homeassistant/components/xiaomi_aqara/translations/he.json index 5a12ddc3b9e..7450bbd463c 100644 --- a/homeassistant/components/xiaomi_aqara/translations/he.json +++ b/homeassistant/components/xiaomi_aqara/translations/he.json @@ -2,22 +2,41 @@ "config": { "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" + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "not_xiaomi_aqara": "\u05dc\u05d0 \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4, \u05d4\u05ea\u05e7\u05df \u05e9\u05d4\u05ea\u05d2\u05dc\u05d4 \u05dc\u05d0 \u05ea\u05d0\u05dd \u05dc\u05e9\u05e2\u05e8\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd" + }, + "error": { + "discovery_error": "\u05d2\u05d9\u05dc\u05d5\u05d9 \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1-IP \u05e9\u05dc \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d5 \u05e4\u05d5\u05e2\u05dc HomeAssistant \u05db\u05de\u05de\u05e9\u05e7", + "invalid_host": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd, \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", + "invalid_interface": "\u05de\u05de\u05e9\u05e7 \u05e8\u05e9\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_key": "\u05de\u05e4\u05ea\u05d7 \u05e9\u05e2\u05e8 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "invalid_mac": "\u05db\u05ea\u05d5\u05d1\u05ea Mac \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea" }, "flow_title": "{name}", "step": { "select": { "data": { "select_ip": "\u05db\u05ea\u05d5\u05d1\u05ea IP" - } + }, + "description": "\u05d9\u05e9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4 \u05e9\u05d5\u05d1 \u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8 \u05e9\u05e2\u05e8\u05d9\u05dd \u05e0\u05d5\u05e1\u05e4\u05d9\u05dd", + "title": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e9\u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8" }, "settings": { - "description": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d0\u05d7\u05d6\u05e8 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 (\u05e1\u05d9\u05e1\u05de\u05d4) \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05d3\u05e8\u05db\u05d4 \u05d6\u05d5: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. \u05d0\u05dd \u05d4\u05de\u05e4\u05ea\u05d7 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05d5\u05e4\u05e7, \u05e8\u05e7 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d9\u05d4\u05d9\u05d5 \u05e0\u05d2\u05d9\u05e9\u05d9\u05dd" + "data": { + "key": "\u05de\u05e4\u05ea\u05d7 \u05d4\u05e9\u05e2\u05e8 \u05e9\u05dc\u05da", + "name": "\u05e9\u05dd \u05d4\u05e9\u05e2\u05e8" + }, + "description": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d0\u05d7\u05d6\u05e8 \u05d0\u05ea \u05d4\u05de\u05e4\u05ea\u05d7 (\u05e1\u05d9\u05e1\u05de\u05d4) \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05d3\u05e8\u05db\u05d4 \u05d6\u05d5: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. \u05d0\u05dd \u05d4\u05de\u05e4\u05ea\u05d7 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05d5\u05e4\u05e7, \u05e8\u05e7 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d9\u05d4\u05d9\u05d5 \u05e0\u05d2\u05d9\u05e9\u05d9\u05dd", + "title": "\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4, \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9\u05d5\u05ea" }, "user": { "data": { - "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" - } + "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "interface": "\u05de\u05de\u05e9\u05e7 \u05d4\u05e8\u05e9\u05ea \u05d1\u05d5 \u05d9\u05e9 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9", + "mac": "\u05db\u05ea\u05d5\u05d1\u05ea Mac (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + }, + "description": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e9\u05dc\u05da, \u05d0\u05dd \u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05d4-IP \u05d5\u05d4-MAC \u05d9\u05d5\u05d5\u05ea\u05e8\u05d5 \u05e8\u05d9\u05e7\u05d5\u05ea, \u05e0\u05e2\u05e9\u05d4 \u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d2\u05d9\u05dc\u05d5\u05d9 \u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", + "title": "\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4" } } } diff --git a/homeassistant/components/xiaomi_aqara/translations/hu.json b/homeassistant/components/xiaomi_aqara/translations/hu.json index 675ef24af3b..a6139c8851b 100644 --- a/homeassistant/components/xiaomi_aqara/translations/hu.json +++ b/homeassistant/components/xiaomi_aqara/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", "not_xiaomi_aqara": "Nem egy Xiaomi Aqara Gateway, a felfedezett eszk\u00f6z nem egyezett az ismert \u00e1tj\u00e1r\u00f3kkal" }, "error": { diff --git a/homeassistant/components/xiaomi_aqara/translations/id.json b/homeassistant/components/xiaomi_aqara/translations/id.json index 5a2acfa330a..eeab548f681 100644 --- a/homeassistant/components/xiaomi_aqara/translations/id.json +++ b/homeassistant/components/xiaomi_aqara/translations/id.json @@ -12,7 +12,7 @@ "invalid_key": "Kunci gateway tidak valid", "invalid_mac": "Alamat MAC Tidak Valid" }, - "flow_title": "Xiaomi Aqara Gateway: {name}", + "flow_title": "{name}", "step": { "select": { "data": { diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 7dac02aaa53..157199e977a 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -9,10 +9,16 @@ from miio import ( AirHumidifierMiot, AirHumidifierMjjsq, AirPurifier, + AirPurifierMB4, AirPurifierMiot, DeviceException, Fan, + Fan1C, FanP5, + FanP9, + FanP10, + FanP11, + FanZA5, ) from miio.gateway.gateway import GatewayException @@ -31,7 +37,13 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRPURIFIER_3C, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, + MODEL_FAN_ZA5, MODELS_AIR_MONITOR, MODELS_FAN, MODELS_FAN_MIIO, @@ -50,7 +62,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan", "number", "select", "sensor", "switch"] +FAN_PLATFORMS = ["binary_sensor", "fan", "number", "select", "sensor", "switch"] HUMIDIFIER_PLATFORMS = [ "binary_sensor", "humidifier", @@ -63,6 +75,15 @@ LIGHT_PLATFORMS = ["light"] VACUUM_PLATFORMS = ["vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] +MODEL_TO_CLASS_MAP = { + MODEL_FAN_1C: Fan1C, + MODEL_FAN_P10: FanP10, + MODEL_FAN_P11: FanP11, + MODEL_FAN_P5: FanP5, + MODEL_FAN_P9: FanP9, + MODEL_FAN_ZA5: FanZA5, +} + async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry @@ -139,6 +160,8 @@ async def async_create_miio_device_and_coordinator( device = AirHumidifier(host, token, model=model) migrate = True # Airpurifiers and Airfresh + elif model in MODEL_AIRPURIFIER_3C: + device = AirPurifierMB4(host, token) elif model in MODELS_PURIFIER_MIOT: device = AirPurifierMiot(host, token) elif model.startswith("zhimi.airpurifier."): @@ -146,8 +169,8 @@ async def async_create_miio_device_and_coordinator( elif model.startswith("zhimi.airfresh."): device = AirFresh(host, token) # Pedestal fans - elif model == MODEL_FAN_P5: - device = FanP5(host, token) + elif model in MODEL_TO_CLASS_MAP: + device = MODEL_TO_CLASS_MAP[model](host, token) elif model in MODELS_FAN_MIIO: device = Fan(host, token, model=model) else: diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 6254c00916e..61c3a4fde61 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -1,12 +1,13 @@ """Support for Xiaomi Miio binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from enum import Enum -from typing import Callable from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PLUG, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -18,6 +19,7 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_FAN_ZA5, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, @@ -25,6 +27,7 @@ from .const import ( from .device import XiaomiCoordinatedMiioEntity ATTR_NO_WATER = "no_water" +ATTR_POWERSUPPLY_ATTACHED = "powersupply_attached" ATTR_WATER_TANK_DETACHED = "water_tank_detached" @@ -48,8 +51,14 @@ BINARY_SENSOR_TYPES = ( device_class=DEVICE_CLASS_CONNECTIVITY, value=lambda value: not value, ), + XiaomiMiioBinarySensorDescription( + key=ATTR_POWERSUPPLY_ATTACHED, + name="Power Supply", + device_class=DEVICE_CLASS_PLUG, + ), ) +FAN_ZA5_BINARY_SENSORS = (ATTR_POWERSUPPLY_ATTACHED,) HUMIDIFIER_MIIO_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MIOT_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED) @@ -62,7 +71,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: model = config_entry.data[CONF_MODEL] sensors = [] - if model in MODELS_HUMIDIFIER_MIIO: + if model in MODEL_FAN_ZA5: + sensors = FAN_ZA5_BINARY_SENSORS + elif model in MODELS_HUMIDIFIER_MIIO: sensors = HUMIDIFIER_MIIO_BINARY_SENSORS elif model in MODELS_HUMIDIFIER_MIOT: sensors = HUMIDIFIER_MIOT_BINARY_SENSORS diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index dce1fdd5e95..cda65bdf0aa 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -30,23 +30,24 @@ SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] DEFAULT_CLOUD_COUNTRY = "cn" # Fan Models -MODEL_AIRPURIFIER_V1 = "zhimi.airpurifier.v1" -MODEL_AIRPURIFIER_V2 = "zhimi.airpurifier.v2" -MODEL_AIRPURIFIER_V3 = "zhimi.airpurifier.v3" -MODEL_AIRPURIFIER_V5 = "zhimi.airpurifier.v5" -MODEL_AIRPURIFIER_PRO = "zhimi.airpurifier.v6" -MODEL_AIRPURIFIER_PRO_V7 = "zhimi.airpurifier.v7" +MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" +MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" +MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4" +MODEL_AIRPURIFIER_3C = "zhimi.airpurifier.mb4" +MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3" MODEL_AIRPURIFIER_M1 = "zhimi.airpurifier.m1" MODEL_AIRPURIFIER_M2 = "zhimi.airpurifier.m2" MODEL_AIRPURIFIER_MA1 = "zhimi.airpurifier.ma1" MODEL_AIRPURIFIER_MA2 = "zhimi.airpurifier.ma2" +MODEL_AIRPURIFIER_PRO = "zhimi.airpurifier.v6" +MODEL_AIRPURIFIER_PROH = "zhimi.airpurifier.va1" +MODEL_AIRPURIFIER_PRO_V7 = "zhimi.airpurifier.v7" MODEL_AIRPURIFIER_SA1 = "zhimi.airpurifier.sa1" MODEL_AIRPURIFIER_SA2 = "zhimi.airpurifier.sa2" -MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" -MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" -MODEL_AIRPURIFIER_3 = "zhimi.airpurifier.ma4" -MODEL_AIRPURIFIER_3H = "zhimi.airpurifier.mb3" -MODEL_AIRPURIFIER_PROH = "zhimi.airpurifier.va1" +MODEL_AIRPURIFIER_V1 = "zhimi.airpurifier.v1" +MODEL_AIRPURIFIER_V2 = "zhimi.airpurifier.v2" +MODEL_AIRPURIFIER_V3 = "zhimi.airpurifier.v3" +MODEL_AIRPURIFIER_V5 = "zhimi.airpurifier.v5" MODEL_AIRHUMIDIFIER_V1 = "zhimi.humidifier.v1" MODEL_AIRHUMIDIFIER_CA1 = "zhimi.humidifier.ca1" @@ -58,13 +59,18 @@ MODEL_AIRHUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_AIRFRESH_VA2 = "zhimi.airfresh.va2" +MODEL_FAN_1C = "dmaker.fan.1c" +MODEL_FAN_P10 = "dmaker.fan.p10" +MODEL_FAN_P11 = "dmaker.fan.p11" MODEL_FAN_P5 = "dmaker.fan.p5" +MODEL_FAN_P9 = "dmaker.fan.p9" MODEL_FAN_SA1 = "zhimi.fan.sa1" MODEL_FAN_V2 = "zhimi.fan.v2" MODEL_FAN_V3 = "zhimi.fan.v3" MODEL_FAN_ZA1 = "zhimi.fan.za1" MODEL_FAN_ZA3 = "zhimi.fan.za3" MODEL_FAN_ZA4 = "zhimi.fan.za4" +MODEL_FAN_ZA5 = "zhimi.fan.za5" MODELS_FAN_MIIO = [ MODEL_FAN_P5, @@ -76,8 +82,17 @@ MODELS_FAN_MIIO = [ MODEL_FAN_ZA4, ] +MODELS_FAN_MIOT = [ + MODEL_FAN_1C, + MODEL_FAN_P10, + MODEL_FAN_P11, + MODEL_FAN_P9, + MODEL_FAN_ZA5, +] + MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, ] @@ -149,7 +164,9 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT + MODELS_FAN_MIIO +MODELS_FAN = ( + MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT + MODELS_FAN_MIIO + MODELS_FAN_MIOT +) MODELS_HUMIDIFIER = ( MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO + MODELS_HUMIDIFIER_MJJSQ ) @@ -234,8 +251,10 @@ FEATURE_SET_FAN_LEVEL = 4096 FEATURE_SET_MOTOR_SPEED = 8192 FEATURE_SET_CLEAN = 16384 FEATURE_SET_OSCILLATION_ANGLE = 32768 -FEATURE_SET_OSCILLATION_ANGLE_MAX_140 = 65536 -FEATURE_SET_DELAY_OFF_COUNTDOWN = 131072 +FEATURE_SET_DELAY_OFF_COUNTDOWN = 65536 +FEATURE_SET_LED_BRIGHTNESS_LEVEL = 131072 +FEATURE_SET_FAVORITE_RPM = 262144 +FEATURE_SET_IONIZER = 524288 FEATURE_FLAGS_AIRPURIFIER_MIIO = ( FEATURE_SET_BUZZER @@ -255,6 +274,13 @@ FEATURE_FLAGS_AIRPURIFIER_MIOT = ( | FEATURE_SET_LED_BRIGHTNESS ) +FEATURE_FLAGS_AIRPURIFIER_3C = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED_BRIGHTNESS_LEVEL + | FEATURE_SET_FAVORITE_RPM +) + FEATURE_FLAGS_AIRPURIFIER_PRO = ( FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED @@ -313,7 +339,7 @@ FEATURE_FLAGS_AIRFRESH = ( FEATURE_FLAGS_FAN_P5 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_OSCILLATION_ANGLE_MAX_140 + | FEATURE_SET_OSCILLATION_ANGLE | FEATURE_SET_LED | FEATURE_SET_DELAY_OFF_COUNTDOWN ) @@ -325,3 +351,35 @@ FEATURE_FLAGS_FAN = ( | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_DELAY_OFF_COUNTDOWN ) + +FEATURE_FLAGS_FAN_ZA5 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_DELAY_OFF_COUNTDOWN + | FEATURE_SET_IONIZER +) + +FEATURE_FLAGS_FAN_1C = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) + +FEATURE_FLAGS_FAN_P9 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) + +FEATURE_FLAGS_FAN_P10_P11 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index ae25fd389b1..04cdc4573db 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,4 +1,5 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" +from abc import abstractmethod import asyncio from enum import Enum import logging @@ -11,6 +12,10 @@ from miio.fan import ( MoveDirection as FanMoveDirection, OperationMode as FanOperationMode, ) +from miio.fan_miot import ( + OperationMode as FanMiotOperationMode, + OperationModeFanZA5 as FanZA5OperationMode, +) import voluptuous as vol from homeassistant.components.fan import ( @@ -20,7 +25,7 @@ from homeassistant.components.fan import ( SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.percentage import ( @@ -34,24 +39,36 @@ from .const import ( DOMAIN, FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, FEATURE_FLAGS_AIRPURIFIER_PRO_V7, FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_1C, FEATURE_FLAGS_FAN_P5, + FEATURE_FLAGS_FAN_P9, + FEATURE_FLAGS_FAN_P10_P11, + FEATURE_FLAGS_FAN_ZA5, FEATURE_RESET_FILTER, FEATURE_SET_EXTRA_FEATURES, KEY_COORDINATOR, KEY_DEVICE, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, + MODEL_FAN_ZA5, MODELS_FAN_MIIO, + MODELS_FAN_MIOT, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_EXTRA_FEATURES, @@ -60,13 +77,10 @@ from .device import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Xiaomi Miio Device" DATA_KEY = "fan.xiaomi_miio" CONF_MODEL = "model" -ATTR_MODEL = "model" - ATTR_MODE_NATURE = "Nature" ATTR_MODE_NORMAL = "Normal" @@ -84,7 +98,6 @@ ATTR_BUTTON_PRESSED = "button_pressed" # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { - ATTR_MODE: "mode", ATTR_EXTRA_FEATURES: "extra_features", ATTR_TURBO_MODE_SUPPORTED: "turbo_mode_supported", ATTR_BUTTON_PRESSED: "button_pressed", @@ -105,16 +118,12 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", } -AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = { - ATTR_MODE: "mode", - ATTR_USE_TIME: "use_time", -} +AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = {ATTR_USE_TIME: "use_time"} AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { # Common set isn't used here. It's a very basic version of the device. - ATTR_MODE: "mode", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", ATTR_EXTRA_FEATURES: "extra_features", @@ -123,29 +132,16 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { } AVAILABLE_ATTRIBUTES_AIRFRESH = { - ATTR_MODE: "mode", ATTR_USE_TIME: "use_time", ATTR_EXTRA_FEATURES: "extra_features", } PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] PRESET_MODES_AIRPURIFIER_MIOT = ["Auto", "Silent", "Favorite", "Fan"] -OPERATION_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] -OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO PRESET_MODES_AIRPURIFIER_PRO_V7 = PRESET_MODES_AIRPURIFIER_PRO -OPERATION_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_2S = ["Auto", "Silent", "Favorite"] -OPERATION_MODES_AIRPURIFIER_3 = ["Auto", "Silent", "Favorite", "Fan"] -OPERATION_MODES_AIRPURIFIER_V3 = [ - "Auto", - "Silent", - "Favorite", - "Idle", - "Medium", - "High", - "Strong", -] +PRESET_MODES_AIRPURIFIER_3C = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_V3 = [ "Auto", "Silent", @@ -155,7 +151,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ "High", "Strong", ] -OPERATION_MODES_AIRFRESH = ["Auto", "Silent", "Interval", "Low", "Middle", "Strong"] PRESET_MODES_AIRFRESH = ["Auto", "Interval"] AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) @@ -193,7 +188,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - if model in MODELS_PURIFIER_MIOT: + if model == MODEL_AIRPURIFIER_3C: + entity = XiaomiAirPurifierMB4( + name, + device, + config_entry, + unique_id, + coordinator, + ) + elif model in MODELS_PURIFIER_MIOT: entity = XiaomiAirPurifierMiot( name, device, @@ -209,6 +212,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity = XiaomiFanP5(name, device, config_entry, unique_id, coordinator) elif model in MODELS_FAN_MIIO: entity = XiaomiFan(name, device, config_entry, unique_id, coordinator) + elif model == MODEL_FAN_ZA5: + entity = XiaomiFanZA5(name, device, config_entry, unique_id, coordinator) + elif model in MODELS_FAN_MIOT: + entity = XiaomiFanMiot(name, device, config_entry, unique_id, coordinator) else: return @@ -262,15 +269,13 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Initialize the generic Xiaomi device.""" super().__init__(name, device, entry, unique_id, coordinator) - self._available = False self._available_attributes = {} self._state = None self._mode = None self._fan_level = None - self._state_attrs = {ATTR_MODEL: self._model} + self._state_attrs = {} self._device_features = 0 self._supported_features = 0 - self._speed_count = 100 self._preset_modes = [] @property @@ -279,9 +284,9 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): return self._supported_features @property - def speed_count(self): - """Return the number of speeds of the fan supported.""" - return self._speed_count + @abstractmethod + def operation_mode_class(self): + """Hold operation mode class.""" @property def preset_modes(self) -> list: @@ -293,16 +298,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Return the percentage based speed of the fan.""" return None - @property - def preset_mode(self): - """Return the percentage based speed of the fan.""" - return None - - @property - def available(self): - """Return true when state is known.""" - return super().available and self._available - @property def extra_state_attributes(self): """Return the state attributes of the device.""" @@ -313,36 +308,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """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 - - @callback - def _handle_coordinator_update(self): - """Fetch state from the device.""" - self._available = True - self._state = self.coordinator.data.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(self.coordinator.data, value) - for key, value in self._available_attributes.items() - } - ) - self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) - self.async_write_ha_state() - - # - # The fan entity model has changed to use percentages and preset_modes - # instead of speeds. - # - # Please review - # https://developers.home-assistant.io/docs/core/entity/fan/ - # async def async_turn_on( self, speed: str = None, @@ -376,15 +341,54 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self.async_write_ha_state() -class XiaomiAirPurifier(XiaomiGenericDevice): - """Representation of a Xiaomi Air Purifier.""" +class XiaomiGenericAirPurifier(XiaomiGenericDevice): + """Representation of a generic AirPurifier device.""" - PRESET_MODE_MAPPING = { - "Auto": AirpurifierOperationMode.Auto, - "Silent": AirpurifierOperationMode.Silent, - "Favorite": AirpurifierOperationMode.Favorite, - "Idle": AirpurifierOperationMode.Favorite, - } + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the generic AirPurifier device.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._speed_count = 100 + + @property + def speed_count(self): + """Return the number of speeds of the fan supported.""" + return self._speed_count + + @property + def preset_mode(self): + """Get the active preset mode.""" + if self._state: + preset_mode = self.operation_mode_class(self._mode).name + return preset_mode if preset_mode in self._preset_modes else None + + return None + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._state_attrs.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } + ) + self._mode = self.coordinator.data.mode.value + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) + self.async_write_ha_state() + + +class XiaomiAirPurifier(XiaomiGenericAirPurifier): + """Representation of a Xiaomi Air Purifier.""" SPEED_MODE_MAPPING = { 1: AirpurifierOperationMode.Silent, @@ -405,63 +409,57 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode elif self._model in MODELS_PURIFIER_MIOT: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._speed_count = 3 - self._operation_mode_class = AirpurifierMiotOperationMode elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode else: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - self._operation_mode_class = AirpurifierOperationMode + self._state = self.coordinator.data.is_on self._state_attrs.update( - {attribute: None for attribute in self._available_attributes} + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } ) - self._mode = self._state_attrs.get(ATTR_MODE) + self._mode = self.coordinator.data.mode.value self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = self._operation_mode_class(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None - - return None + def operation_mode_class(self): + """Hold operation mode class.""" + return AirpurifierOperationMode @property def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = self._operation_mode_class(self._state_attrs[ATTR_MODE]) + mode = self.operation_mode_class(self._mode) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] @@ -485,7 +483,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self._operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), + self.operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -499,9 +497,9 @@ class XiaomiAirPurifier(XiaomiGenericDevice): if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], + self.operation_mode_class[preset_mode], ): - self._mode = self._operation_mode_class[preset_mode].value + self._mode = self.operation_mode_class[preset_mode].value self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): @@ -529,12 +527,10 @@ class XiaomiAirPurifier(XiaomiGenericDevice): class XiaomiAirPurifierMiot(XiaomiAirPurifier): """Representation of a Xiaomi Air Purifier (MiOT protocol).""" - PRESET_MODE_MAPPING = { - "Auto": AirpurifierMiotOperationMode.Auto, - "Silent": AirpurifierMiotOperationMode.Silent, - "Favorite": AirpurifierMiotOperationMode.Favorite, - "Fan": AirpurifierMiotOperationMode.Fan, - } + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return AirpurifierMiotOperationMode @property def percentage(self): @@ -566,24 +562,48 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): self._fan_level = fan_level self.async_write_ha_state() - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode of the fan. - This method is a coroutine. - """ +class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): + """Representation of a Xiaomi Air Purifier MB4.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize Air Purifier MB4.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._device_features = FEATURE_FLAGS_AIRPURIFIER_3C + self._preset_modes = PRESET_MODES_AIRPURIFIER_3C + self._supported_features = SUPPORT_PRESET_MODE + + self._state = self.coordinator.data.is_on + self._mode = self.coordinator.data.mode.value + + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return AirpurifierMiotOperationMode + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], + self.operation_mode_class[preset_mode], ): - self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self._mode = self.operation_mode_class[preset_mode].value self.async_write_ha_state() + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._mode = self.coordinator.data.mode.value + self.async_write_ha_state() -class XiaomiAirFresh(XiaomiGenericDevice): + +class XiaomiAirFresh(XiaomiGenericAirPurifier): """Representation of a Xiaomi Air Fresh.""" SPEED_MODE_MAPPING = { @@ -609,19 +629,20 @@ class XiaomiAirFresh(XiaomiGenericDevice): self._speed_count = 4 self._preset_modes = PRESET_MODES_AIRFRESH self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE + + self._state = self.coordinator.data.is_on self._state_attrs.update( - {attribute: None for attribute in self._available_attributes} + { + key: getattr(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } ) - self._mode = self._state_attrs.get(ATTR_MODE) + self._mode = self.coordinator.data.mode.value @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = AirfreshOperationMode(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None - - return None + def operation_mode_class(self): + """Hold operation mode class.""" + return AirfreshOperationMode @property def percentage(self): @@ -665,9 +686,9 @@ class XiaomiAirFresh(XiaomiGenericDevice): if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], + self.operation_mode_class[preset_mode], ): - self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self._mode = self.operation_mode_class[preset_mode].value self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): @@ -692,26 +713,30 @@ class XiaomiAirFresh(XiaomiGenericDevice): ) -class XiaomiFan(XiaomiGenericDevice): - """Representation of a Xiaomi Fan.""" +class XiaomiGenericFan(XiaomiGenericDevice): + """Representation of a generic Xiaomi Fan.""" def __init__(self, name, device, entry, unique_id, coordinator): - """Initialize the plug switch.""" + """Initialize the fan.""" super().__init__(name, device, entry, unique_id, coordinator) if self._model == MODEL_FAN_P5: self._device_features = FEATURE_FLAGS_FAN_P5 - self._preset_modes = [mode.name for mode in FanOperationMode] + elif self._model == MODEL_FAN_ZA5: + self._device_features = FEATURE_FLAGS_FAN_ZA5 + elif self._model == MODEL_FAN_1C: + self._device_features = FEATURE_FLAGS_FAN_1C + elif self._model == MODEL_FAN_P9: + self._device_features = FEATURE_FLAGS_FAN_P9 + elif self._model in (MODEL_FAN_P10, MODEL_FAN_P11): + self._device_features = FEATURE_FLAGS_FAN_P10_P11 else: self._device_features = FEATURE_FLAGS_FAN - self._preset_modes = [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] - self._nature_mode = False self._supported_features = ( - SUPPORT_SET_SPEED - | SUPPORT_OSCILLATE - | SUPPORT_PRESET_MODE - | SUPPORT_DIRECTION + SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_PRESET_MODE ) + if self._model != MODEL_FAN_1C: + self._supported_features |= SUPPORT_DIRECTION self._preset_mode = None self._oscillating = None self._percentage = None @@ -719,32 +744,87 @@ class XiaomiFan(XiaomiGenericDevice): @property def preset_mode(self): """Get the active preset mode.""" - return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + return self._preset_mode + + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + return [mode.name for mode in self.operation_mode_class] @property def percentage(self): """Return the current speed as a percentage.""" - return self._percentage + if self._state: + return self._percentage + + return None @property def oscillating(self): """Return whether or not the fan is currently oscillating.""" return self._oscillating - @callback - def _handle_coordinator_update(self): - """Fetch state from the device.""" - self._available = True + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + await self._try_command( + "Setting oscillate on/off of the miio device failed.", + self._device.set_oscillate, + oscillating, + ) + self._oscillating = oscillating + self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._oscillating: + await self.async_oscillate(oscillating=False) + + await self._try_command( + "Setting move direction of the miio device failed.", + self._device.set_rotate, + FanMoveDirection(FAN_DIRECTIONS_MAP[direction]), + ) + + +class XiaomiFan(XiaomiGenericFan): + """Representation of a Xiaomi Fan.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the fan.""" + super().__init__(name, device, entry, unique_id, coordinator) + self._state = self.coordinator.data.is_on self._oscillating = self.coordinator.data.oscillate self._nature_mode = self.coordinator.data.natural_speed != 0 - if self.coordinator.data.is_on: - if self._nature_mode: - self._percentage = self.coordinator.data.natural_speed - else: - self._percentage = self.coordinator.data.direct_speed + if self._nature_mode: + self._percentage = self.coordinator.data.natural_speed else: - self._percentage = 0 + self._percentage = self.coordinator.data.direct_speed + + @property + def operation_mode_class(self): + """Hold operation mode class.""" + + @property + def preset_mode(self): + """Get the active preset mode.""" + return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + return [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._oscillating = self.coordinator.data.oscillate + self._nature_mode = self.coordinator.data.natural_speed != 0 + if self._nature_mode: + self._percentage = self.coordinator.data.natural_speed + else: + self._percentage = self.coordinator.data.direct_speed self.async_write_ha_state() @@ -796,47 +876,31 @@ class XiaomiFan(XiaomiGenericDevice): else: self.async_write_ha_state() - async def async_oscillate(self, oscillating: bool) -> None: - """Set oscillation.""" - await self._try_command( - "Setting oscillate on/off of the miio device failed.", - self._device.set_oscillate, - oscillating, - ) - self._oscillating = oscillating - self.async_write_ha_state() - async def async_set_direction(self, direction: str) -> None: - """Set the direction of the fan.""" - if self._oscillating: - await self.async_oscillate(oscillating=False) - - await self._try_command( - "Setting move direction of the miio device failed.", - self._device.set_rotate, - FanMoveDirection(FAN_DIRECTIONS_MAP[direction]), - ) - - -class XiaomiFanP5(XiaomiFan): +class XiaomiFanP5(XiaomiGenericFan): """Representation of a Xiaomi Fan P5.""" + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the fan.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._state = self.coordinator.data.is_on + self._preset_mode = self.coordinator.data.mode.name + self._oscillating = self.coordinator.data.oscillate + self._percentage = self.coordinator.data.speed + @property - def preset_mode(self): - """Get the active preset mode.""" - return self._preset_mode + def operation_mode_class(self): + """Hold operation mode class.""" + return FanOperationMode @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - self._available = True self._state = self.coordinator.data.is_on self._preset_mode = self.coordinator.data.mode.name self._oscillating = self.coordinator.data.oscillate - if self.coordinator.data.is_on: - self._percentage = self.coordinator.data.speed - else: - self._percentage = 0 + self._percentage = self.coordinator.data.speed self.async_write_ha_state() @@ -848,7 +912,7 @@ class XiaomiFanP5(XiaomiFan): await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - FanOperationMode[preset_mode], + self.operation_mode_class[preset_mode], ) self._preset_mode = preset_mode self.async_write_ha_state() @@ -871,3 +935,71 @@ class XiaomiFanP5(XiaomiFan): await self.async_turn_on() else: self.async_write_ha_state() + + +class XiaomiFanMiot(XiaomiGenericFan): + """Representation of a Xiaomi Fan Miot.""" + + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return FanMiotOperationMode + + @property + def preset_mode(self): + """Get the active preset mode.""" + return self._preset_mode + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._preset_mode = self.coordinator.data.mode.name + self._oscillating = self.coordinator.data.oscillate + if self.coordinator.data.is_on: + self._percentage = self.coordinator.data.fan_speed + else: + self._percentage = 0 + + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + 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.operation_mode_class[preset_mode], + ) + self._preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage of the fan.""" + if percentage == 0: + self._percentage = 0 + await self.async_turn_off() + return + + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_speed, + percentage, + ) + self._percentage = percentage + + if not self.is_on: + await self.async_turn_on() + else: + self.async_write_ha_state() + + +class XiaomiFanZA5(XiaomiFanMiot): + """Representation of a Xiaomi Fan ZA5.""" + + @property + def operation_mode_class(self): + """Hold operation mode class.""" + return FanZA5OperationMode diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 584d5caf6b5..411d1428c70 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -9,8 +9,6 @@ from miio.airhumidifier_mjjsq import OperationMode as AirhumidifierMjjsqOperatio from homeassistant.components.humidifier import HumidifierEntity from homeassistant.components.humidifier.const import ( - DEFAULT_MAX_HUMIDITY, - DEFAULT_MIN_HUMIDITY, DEVICE_CLASS_HUMIDIFIER, SUPPORT_MODES, ) @@ -117,10 +115,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): 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 @@ -137,26 +132,11 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): 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, @@ -196,25 +176,20 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id, coordinator) + + self._attr_min_humidity = 30 + self._attr_max_humidity = 80 if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - self._available_modes = AVAILABLE_MODES_CA1_CB1 - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_CA1_CB1 self._humidity_steps = 10 elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: - self._available_modes = AVAILABLE_MODES_CA4 - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_CA4 self._humidity_steps = 100 elif self._model in MODELS_HUMIDIFIER_MJJSQ: - self._available_modes = AVAILABLE_MODES_MJJSQ - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_MJJSQ self._humidity_steps = 100 else: - self._available_modes = AVAILABLE_MODES_OTHER - self._min_humidity = 30 - self._max_humidity = 80 + self._attr_available_modes = AVAILABLE_MODES_OTHER self._humidity_steps = 10 self._state = self.coordinator.data.is_on diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index af5f29306a0..a915ce57847 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -17,6 +17,7 @@ from .const import ( FEATURE_FLAGS_AIRHUMIDIFIER_CA4, FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -24,13 +25,18 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_V1, FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_1C, FEATURE_FLAGS_FAN_P5, + FEATURE_FLAGS_FAN_P9, + FEATURE_FLAGS_FAN_P10_P11, + FEATURE_FLAGS_FAN_ZA5, FEATURE_SET_DELAY_OFF_COUNTDOWN, FEATURE_SET_FAN_LEVEL, FEATURE_SET_FAVORITE_LEVEL, + FEATURE_SET_FAVORITE_RPM, + FEATURE_SET_LED_BRIGHTNESS_LEVEL, FEATURE_SET_MOTOR_SPEED, FEATURE_SET_OSCILLATION_ANGLE, - FEATURE_SET_OSCILLATION_ANGLE_MAX_140, FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, @@ -39,17 +45,23 @@ from .const import ( MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, MODEL_FAN_SA1, MODEL_FAN_V2, MODEL_FAN_V3, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODEL_FAN_ZA5, MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, ) @@ -58,6 +70,8 @@ from .device import XiaomiCoordinatedMiioEntity ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" ATTR_FAN_LEVEL = "fan_level" ATTR_FAVORITE_LEVEL = "favorite_level" +ATTR_FAVORITE_RPM = "favorite_rpm" +ATTR_LED_BRIGHTNESS_LEVEL = "led_brightness_level" ATTR_MOTOR_SPEED = "motor_speed" ATTR_OSCILLATION_ANGLE = "angle" ATTR_VOLUME = "volume" @@ -74,6 +88,15 @@ class XiaomiMiioNumberDescription(NumberEntityDescription): method: str | None = None +@dataclass +class OscillationAngleValues: + """A class that describes oscillation angle values.""" + + max_value: float | None = None + min_value: float | None = None + step: float | None = None + + NUMBER_TYPES = { FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription( key=ATTR_MOTOR_SPEED, @@ -123,16 +146,6 @@ NUMBER_TYPES = { step=1, method="async_set_oscillation_angle", ), - FEATURE_SET_OSCILLATION_ANGLE_MAX_140: XiaomiMiioNumberDescription( - key=ATTR_OSCILLATION_ANGLE, - name="Oscillation Angle", - icon="mdi:angle-acute", - unit_of_measurement=DEGREE, - min_value=30, - max_value=140, - step=30, - method="async_set_oscillation_angle", - ), FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription( key=ATTR_DELAY_OFF_COUNTDOWN, name="Delay Off Countdown", @@ -143,6 +156,25 @@ NUMBER_TYPES = { step=1, method="async_set_delay_off_countdown", ), + FEATURE_SET_LED_BRIGHTNESS_LEVEL: XiaomiMiioNumberDescription( + key=ATTR_LED_BRIGHTNESS_LEVEL, + name="Led Brightness", + icon="mdi:brightness-6", + min_value=0, + max_value=8, + step=1, + method="async_set_led_brightness_level", + ), + FEATURE_SET_FAVORITE_RPM: XiaomiMiioNumberDescription( + key=ATTR_FAVORITE_RPM, + name="Favorite Motor Speed", + icon="mdi:star-cog", + unit_of_measurement="rpm", + min_value=300, + max_value=2300, + step=10, + method="async_set_favorite_rpm", + ), } MODEL_TO_FEATURES_MAP = { @@ -151,17 +183,31 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C: FEATURE_FLAGS_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, + MODEL_FAN_P10: FEATURE_FLAGS_FAN_P10_P11, + MODEL_FAN_P11: FEATURE_FLAGS_FAN_P10_P11, MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_P9: FEATURE_FLAGS_FAN_P9, MODEL_FAN_SA1: FEATURE_FLAGS_FAN, MODEL_FAN_V2: FEATURE_FLAGS_FAN, MODEL_FAN_V3: FEATURE_FLAGS_FAN, MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA5: FEATURE_FLAGS_FAN_ZA5, +} + +OSCILLATION_ANGLE_VALUES = { + MODEL_FAN_P5: OscillationAngleValues(max_value=140, min_value=30, step=30), + MODEL_FAN_ZA5: OscillationAngleValues(max_value=120, min_value=30, step=30), + MODEL_FAN_P9: OscillationAngleValues(max_value=150, min_value=30, step=30), + MODEL_FAN_P10: OscillationAngleValues(max_value=140, min_value=30, step=30), + MODEL_FAN_P11: OscillationAngleValues(max_value=140, min_value=30, step=30), } @@ -184,6 +230,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for feature, description in NUMBER_TYPES.items(): if feature & features: + if ( + description.key == ATTR_OSCILLATION_ANGLE + and model in OSCILLATION_ANGLE_VALUES + ): + description.max_value = OSCILLATION_ANGLE_VALUES[model].max_value + description.min_value = OSCILLATION_ANGLE_VALUES[model].min_value + description.step = OSCILLATION_ANGLE_VALUES[model].step entities.append( XiaomiNumberEntity( f"{config_entry.title} {description.name}", @@ -248,7 +301,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): ) self.async_write_ha_state() - async def async_set_motor_speed(self, motor_speed: int = 400): + async def async_set_motor_speed(self, motor_speed: int = 400) -> bool: """Set the target motor speed.""" return await self._try_command( "Setting the target motor speed of the miio device failed.", @@ -256,7 +309,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): motor_speed, ) - async def async_set_favorite_level(self, level: int = 1): + async def async_set_favorite_level(self, level: int = 1) -> bool: """Set the favorite level.""" return await self._try_command( "Setting the favorite level of the miio device failed.", @@ -264,7 +317,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): level, ) - async def async_set_fan_level(self, level: int = 1): + async def async_set_fan_level(self, level: int = 1) -> bool: """Set the fan level.""" return await self._try_command( "Setting the favorite level of the miio device failed.", @@ -272,7 +325,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): level, ) - async def async_set_volume(self, volume: int = 50): + async def async_set_volume(self, volume: int = 50) -> bool: """Set the volume.""" return await self._try_command( "Setting the volume of the miio device failed.", @@ -280,16 +333,32 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): volume, ) - async def async_set_oscillation_angle(self, angle: int): + async def async_set_oscillation_angle(self, angle: int) -> bool: """Set the volume.""" return await self._try_command( "Setting angle of the miio device failed.", self._device.set_angle, angle ) - async def async_set_delay_off_countdown(self, delay_off_countdown: int): + async def async_set_delay_off_countdown(self, delay_off_countdown: int) -> bool: """Set the delay off countdown.""" return await self._try_command( "Setting delay off miio device failed.", self._device.delay_off, delay_off_countdown * 60, ) + + async def async_set_led_brightness_level(self, level: int): + """Set the led brightness level.""" + return await self._try_command( + "Setting the led brightness level of the miio device failed.", + self._device.set_led_brightness_level, + level, + ) + + async def async_set_favorite_rpm(self, rpm: int): + """Set the target motor speed.""" + return await self._try_command( + "Setting the favorite rpm of the miio device failed.", + self._device.set_favorite_rpm, + rpm, + ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index b43291dfeef..daa721fef95 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -23,6 +23,7 @@ from .const import ( KEY_COORDINATOR, KEY_DEVICE, MODEL_AIRFRESH_VA2, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, MODEL_FAN_SA1, @@ -75,6 +76,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + if model == MODEL_AIRPURIFIER_3C: + return if model in MODELS_HUMIDIFIER_MIIO: entity_class = XiaomiAirHumidifierSelector elif model in MODELS_HUMIDIFIER_MIOT: diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 3a50ffe89c0..d199c051eae 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, @@ -56,6 +57,7 @@ from .const import ( MODEL_AIRFRESH_VA2, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V2, @@ -66,6 +68,7 @@ from .const import ( MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODEL_FAN_ZA5, MODELS_AIR_QUALITY_MONITOR, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, @@ -195,7 +198,7 @@ SENSOR_TYPES = { key=ATTR_AQI, name="PM2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM25, state_class=STATE_CLASS_MEASUREMENT, ), ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( @@ -269,6 +272,12 @@ PURIFIER_MIOT_SENSORS = ( ATTR_PURIFY_VOLUME, ATTR_TEMPERATURE, ) +PURIFIER_3C_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_MOTOR_SPEED, + ATTR_PM25, +) PURIFIER_V2_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_USE, @@ -323,16 +332,20 @@ FAN_V2_V3_SENSORS = ( ATTR_TEMPERATURE, ) +FAN_ZA5_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) + MODEL_TO_SENSORS_MAP = { MODEL_AIRFRESH_VA2: AIRFRESH_SENSORS, MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, + MODEL_AIRPURIFIER_3C: PURIFIER_3C_SENSORS, MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, MODEL_AIRPURIFIER_PRO_V7: PURIFIER_PRO_V7_SENSORS, MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, MODEL_FAN_V2: FAN_V2_V3_SENSORS, MODEL_FAN_V3: FAN_V2_V3_SENSORS, + MODEL_FAN_ZA5: FAN_ZA5_SENSORS, } diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index c40711f5266..fd9d4053313 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -38,6 +38,7 @@ from .const import ( FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ, FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -45,12 +46,17 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_V1, FEATURE_FLAGS_AIRPURIFIER_V3, FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_1C, FEATURE_FLAGS_FAN_P5, + FEATURE_FLAGS_FAN_P9, + FEATURE_FLAGS_FAN_P10_P11, + FEATURE_FLAGS_FAN_ZA5, FEATURE_SET_AUTO_DETECT, FEATURE_SET_BUZZER, FEATURE_SET_CHILD_LOCK, FEATURE_SET_CLEAN, FEATURE_SET_DRY, + FEATURE_SET_IONIZER, FEATURE_SET_LEARN_MODE, FEATURE_SET_LED, KEY_COORDINATOR, @@ -61,14 +67,20 @@ from .const import ( MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3, + MODEL_FAN_1C, MODEL_FAN_P5, + MODEL_FAN_P9, + MODEL_FAN_P10, + MODEL_FAN_P11, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODEL_FAN_ZA5, MODELS_FAN, MODELS_HUMIDIFIER, MODELS_HUMIDIFIER_MJJSQ, @@ -106,6 +118,7 @@ ATTR_CLEAN = "clean_mode" ATTR_DRY = "dry" ATTR_LEARN_MODE = "learn_mode" ATTR_LED = "led" +ATTR_IONIZER = "ionizer" ATTR_LOAD_POWER = "load_power" ATTR_MODEL = "model" ATTR_POWER = "power" @@ -158,14 +171,20 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, MODEL_AIRPURIFIER_2H: FEATURE_FLAGS_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_3C: FEATURE_FLAGS_AIRPURIFIER_3C, MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, + MODEL_FAN_P10: FEATURE_FLAGS_FAN_P10_P11, + MODEL_FAN_P11: FEATURE_FLAGS_FAN_P10_P11, MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_P9: FEATURE_FLAGS_FAN_P9, MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA5: FEATURE_FLAGS_FAN_ZA5, } @@ -208,7 +227,7 @@ SWITCH_TYPES = ( key=ATTR_CLEAN, feature=FEATURE_SET_CLEAN, name="Clean Mode", - icon="mdi:sparkles", + icon="mdi:shimmer", method_on="async_set_clean_on", method_off="async_set_clean_off", available_with_device_off=False, @@ -236,6 +255,14 @@ SWITCH_TYPES = ( method_on="async_set_auto_detect_on", method_off="async_set_auto_detect_off", ), + XiaomiMiioSwitchDescription( + key=ATTR_IONIZER, + feature=FEATURE_SET_IONIZER, + name="Ionizer", + icon="mdi:shimmer", + method_on="async_set_ionizer_on", + method_off="async_set_ionizer_off", + ), ) @@ -575,6 +602,22 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): False, ) + async def async_set_ionizer_on(self) -> bool: + """Turn ionizer on.""" + return await self._try_command( + "Turning ionizer of the miio device on failed.", + self._device.set_ionizer, + True, + ) + + async def async_set_ionizer_off(self) -> bool: + """Turn ionizer off.""" + return await self._try_command( + "Turning ionizer of the miio device off failed.", + self._device.set_ionizer, + False, + ) + class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 26f5b3937b6..d70445d2aa5 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -4,7 +4,8 @@ "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para este dispositivo Xiaomi Miio ya est\u00e1 en marcha.", "incomplete_info": "Informaci\u00f3n incompleta para configurar el dispositivo, no se ha suministrado ning\u00fan host o token.", - "not_xiaomi_miio": "El dispositivo no es (todav\u00eda) compatible con Xiaomi Miio." + "not_xiaomi_miio": "El dispositivo no es (todav\u00eda) compatible con Xiaomi Miio.", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -53,8 +54,12 @@ "title": "Conectar con un Xiaomi Gateway" }, "manual": { - "description": "Necesitar\u00e1 la clave de 32 caracteres Token API, consulte https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token para obtener instrucciones. Tenga en cuenta que esta Token API es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", - "title": "Con\u00e9ctese a un dispositivo Xiaomi Miio o una puerta de enlace Xiaomi" + "data": { + "host": "Direcci\u00f3n IP", + "token": "Token API" + }, + "description": "Necesitar\u00e1s la clave de 32 caracteres Token API, consulta https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token para obtener instrucciones. Ten en cuenta que esta Token API es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", + "title": "Con\u00e9ctate 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.", @@ -78,14 +83,14 @@ }, "options": { "error": { - "cloud_credentials_incomplete": "Las credenciales de la nube est\u00e1n incompletas, por favor, rellene el nombre de usuario, la contrase\u00f1a y el pa\u00eds" + "cloud_credentials_incomplete": "Las credenciales de la nube est\u00e1n incompletas, por favor, rellena el nombre de usuario, la contrase\u00f1a y el pa\u00eds" }, "step": { "init": { "data": { - "cloud_subdevices": "Utilice la nube para conectar subdispositivos" + "cloud_subdevices": "Utiliza la nube para conectar subdispositivos" }, - "description": "Especifique los ajustes opcionales", + "description": "Especifica los ajustes opcionales", "title": "Xiaomi Miio" } } diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 2b68325a246..06a0d29c608 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -2,10 +2,10 @@ "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": "La configuration 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" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -56,14 +56,14 @@ "manual": { "data": { "host": "Adresse IP", - "token": "Jeton API" + "token": "Jeton d'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.", + "description": "Vous aurez besoin du Jeton d'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 d'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" + "title": "R\u00e9-authentifier l'int\u00e9gration" }, "select": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/hu.json b/homeassistant/components/xiaomi_miio/translations/hu.json index 1747b51c61a..6d53aad6f56 100644 --- a/homeassistant/components/xiaomi_miio/translations/hu.json +++ b/homeassistant/components/xiaomi_miio/translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "already_in_progress": "A konfigur\u00e1l\u00e1s 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" @@ -41,7 +41,7 @@ "name": "Eszk\u00f6z neve", "token": "API Token" }, - "description": "Sz\u00fcks\u00e9ged lesz a 32 karakteres API Tokenre, k\u00f6vesd a https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token oldal instrukci\u00f3it. Vedd figyelembe, hogy ez az API Token k\u00fcl\u00f6nb\u00f6zik a Xiaomi Aqara integr\u00e1ci\u00f3 \u00e1ltal haszn\u00e1lt kulcst\u00f3l.", + "description": "Sz\u00fcks\u00e9ge lesz a 32 karakteres API Tokenre, k\u00f6vesse a https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token oldal instrukci\u00f3it. Vegye figyelembe, hogy ez az API Token k\u00fcl\u00f6nb\u00f6zik a Xiaomi Aqara integr\u00e1ci\u00f3 \u00e1ltal haszn\u00e1lt kulcst\u00f3l.", "title": "Csatlakoz\u00e1s Xiaomi Miio eszk\u00f6zh\u00f6z vagy Xiaomi Gateway-hez" }, "gateway": { diff --git a/homeassistant/components/xiaomi_miio/translations/id.json b/homeassistant/components/xiaomi_miio/translations/id.json index f893f7b06aa..a6217b52eb1 100644 --- a/homeassistant/components/xiaomi_miio/translations/id.json +++ b/homeassistant/components/xiaomi_miio/translations/id.json @@ -2,14 +2,15 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "already_in_progress": "Alur konfigurasi sedang berlangsung" + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", "no_device_selected": "Tidak ada perangkat yang dipilih, pilih satu perangkat.", "unknown_device": "Model perangkat tidak diketahui, tidak dapat menyiapkan perangkat menggunakan alur konfigurasi." }, - "flow_title": "Xiaomi Miio: {name}", + "flow_title": "{name}", "step": { "cloud": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/select.id.json b/homeassistant/components/xiaomi_miio/translations/select.id.json new file mode 100644 index 00000000000..178bc06301c --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Terang", + "dim": "Redup", + "off": "Mati" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json index ed977dc9cd5..3c3152db0da 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json +++ b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json @@ -2,7 +2,7 @@ "state": { "xiaomi_miio__led_brightness": { "bright": "\u4eae\u5149", - "dim": "\u8abf\u5149", + "dim": "\u5fae\u5149", "off": "\u95dc\u9589" } } diff --git a/homeassistant/components/yale_smart_alarm/translations/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json index ab77170999b..04e894afe1b 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ca.json +++ b/homeassistant/components/yale_smart_alarm/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja est\u00e0 configurat" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" diff --git a/homeassistant/components/yale_smart_alarm/translations/el.json b/homeassistant/components/yale_smart_alarm/translations/el.json new file mode 100644 index 00000000000..676d0889008 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/el.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "area_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03c1\u03b9\u03bf\u03c7\u03ae\u03c2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/es.json b/homeassistant/components/yale_smart_alarm/translations/es.json index b970badb079..178b8209af7 100644 --- a/homeassistant/components/yale_smart_alarm/translations/es.json +++ b/homeassistant/components/yale_smart_alarm/translations/es.json @@ -1,26 +1,26 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" + "already_configured": "La cuenta ya ha sido configurada" }, "error": { - "invalid_auth": "Autenticaci\u00f3n err\u00f3nea" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, "step": { "reauth_confirm": { "data": { - "area_id": "ID de \u00c1rea", + "area_id": "ID de \u00e1rea", "name": "Nombre", - "password": "Clave", - "username": "Nombre de usuario" + "password": "Contrase\u00f1a", + "username": "Usuario" } }, "user": { "data": { "area_id": "ID de \u00e1rea", "name": "Nombre", - "password": "Clave", - "username": "Nombre de usuario" + "password": "Contrase\u00f1a", + "username": "Usuario" } } } diff --git a/homeassistant/components/yale_smart_alarm/translations/fr.json b/homeassistant/components/yale_smart_alarm/translations/fr.json index 60d6f5cc548..c2cf20086e2 100644 --- a/homeassistant/components/yale_smart_alarm/translations/fr.json +++ b/homeassistant/components/yale_smart_alarm/translations/fr.json @@ -4,7 +4,7 @@ "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "invalid_auth": "Authentification incorrecte" + "invalid_auth": "Authentification invalide" }, "step": { "reauth_confirm": { diff --git a/homeassistant/components/yale_smart_alarm/translations/id.json b/homeassistant/components/yale_smart_alarm/translations/id.json new file mode 100644 index 00000000000..ee24f03a33c --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID Area", + "name": "Nama", + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, + "user": { + "data": { + "area_id": "ID Area", + "name": "Nama", + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 0de8428b0dc..9f691773e11 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -30,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=60) async def get_upnp_desc(hass: HomeAssistant, host: str): """Get the upnp description URL for a given host, using the SSPD scanner.""" - ssdp_entries = ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") + ssdp_entries = await ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") matches = [w for w in ssdp_entries if w.get("_host", "") == host] upnp_desc = None for match in matches: diff --git a/homeassistant/components/yamaha_musiccast/translations/es.json b/homeassistant/components/yamaha_musiccast/translations/es.json index c63baa7b576..46f8a02f33d 100644 --- a/homeassistant/components/yamaha_musiccast/translations/es.json +++ b/homeassistant/components/yamaha_musiccast/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", "yxc_control_url_missing": "La URL de control no se proporciona en la descripci\u00f3n del ssdp." }, "error": { @@ -8,7 +9,13 @@ }, "flow_title": "MusicCast: {name}", "step": { + "confirm": { + "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" + }, "user": { + "data": { + "host": "Anfitri\u00f3n" + }, "description": "Configura MusicCast para integrarse con Home Assistant." } } diff --git a/homeassistant/components/yamaha_musiccast/translations/fr.json b/homeassistant/components/yamaha_musiccast/translations/fr.json index 0a8671dc2aa..14cbec9e877 100644 --- a/homeassistant/components/yamaha_musiccast/translations/fr.json +++ b/homeassistant/components/yamaha_musiccast/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "Voulez-vous commencer la configuration\u00a0?" + "description": "Voulez-vous commencer la configuration ?" }, "user": { "data": { diff --git a/homeassistant/components/yamaha_musiccast/translations/hu.json b/homeassistant/components/yamaha_musiccast/translations/hu.json index 9ddf75ca732..fc2672f5839 100644 --- a/homeassistant/components/yamaha_musiccast/translations/hu.json +++ b/homeassistant/components/yamaha_musiccast/translations/hu.json @@ -10,11 +10,11 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, "description": "\u00c1ll\u00edtsa be a MusicCast-ot a Homeassistanttal val\u00f3 integr\u00e1ci\u00f3hoz." } diff --git a/homeassistant/components/yamaha_musiccast/translations/nl.json b/homeassistant/components/yamaha_musiccast/translations/nl.json index e1e31149c06..8cb8265a1f0 100644 --- a/homeassistant/components/yamaha_musiccast/translations/nl.json +++ b/homeassistant/components/yamaha_musiccast/translations/nl.json @@ -10,7 +10,7 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" }, "user": { "data": { diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index ed4ce0cbf58..a1dce44893b 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -6,9 +6,10 @@ import contextlib from datetime import timedelta from ipaddress import IPv4Address, IPv6Address import logging +import socket from urllib.parse import urlparse -from async_upnp_client.search import SSDPListener +from async_upnp_client.search import SsdpSearchListener import voluptuous as vol from yeelight import BulbException from yeelight.aio import KEY_CONNECTED, AsyncBulb @@ -35,6 +36,9 @@ from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) +STATE_CHANGE_TIME = 0.25 # seconds + + DOMAIN = "yeelight" DATA_YEELIGHT = DOMAIN DATA_UPDATED = "yeelight_{}_data_updated" @@ -163,7 +167,9 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] -BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError) +BULB_NETWORK_EXCEPTIONS = (socket.error,) +BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError, *BULB_NETWORK_EXCEPTIONS) + PLATFORMS = ["binary_sensor", "light"] @@ -175,6 +181,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}), DATA_CONFIG_ENTRIES: {}, } + # Make sure the scanner is always started in case we are + # going to retry via ConfigEntryNotReady and the bulb has changed + # ip + scanner = YeelightScanner.async_get(hass) + await scanner.async_setup() # Import manually configured devices for host, device_config in config.get(DOMAIN, {}).get(CONF_DEVICES, {}).items(): @@ -275,11 +286,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) except BULB_EXCEPTIONS as ex: - # If CONF_ID is not valid we cannot fallback to discovery - # so we must retry by raising ConfigEntryNotReady - if not entry.data.get(CONF_ID): - raise ConfigEntryNotReady from ex - # Otherwise fall through to discovery + # Always retry later since bulbs can stop responding to SSDP + # sometimes even though they are online. If it has changed + # IP we will update it via discovery to the config flow + raise ConfigEntryNotReady from ex else: # Since device is passed this cannot throw an exception anymore await _async_initialize(hass, entry, entry.data[CONF_HOST], device=device) @@ -292,7 +302,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except BULB_EXCEPTIONS: _LOGGER.exception("Failed to connect to bulb at %s", host) - # discovery scanner = YeelightScanner.async_get(hass) await scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) return True @@ -300,18 +309,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - data_config_entries = hass.data[DOMAIN][DATA_CONFIG_ENTRIES] - entry_data = data_config_entries[entry.entry_id] - - if entry_data[DATA_PLATFORMS_LOADED]: - if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - return False - if entry.data.get(CONF_ID): # discovery scanner = YeelightScanner.async_get(hass) scanner.async_unregister_callback(entry.data[CONF_ID]) + data_config_entries = hass.data[DOMAIN][DATA_CONFIG_ENTRIES] + if entry.entry_id not in data_config_entries: + # Device not online + return True + + entry_data = data_config_entries[entry.entry_id] + unload_ok = True + if entry_data[DATA_PLATFORMS_LOADED]: + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if DATA_DEVICE in entry_data: device = entry_data[DATA_DEVICE] _LOGGER.debug("Shutting down Yeelight Listener") @@ -319,8 +331,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Yeelight Listener stopped") data_config_entries.pop(entry.entry_id) - - return True + return unload_ok @callback @@ -395,7 +406,7 @@ class YeelightScanner: return _async_connected self._listeners.append( - SSDPListener( + SsdpSearchListener( async_callback=self._async_process_entry, service_type=SSDP_ST, target=SSDP_TARGET, @@ -493,7 +504,9 @@ class YeelightScanner: _LOGGER.debug("Discovered via SSDP: %s", response) unique_id = response["id"] host = urlparse(response["location"]).hostname - if unique_id not in self._unique_id_capabilities: + current_entry = self._unique_id_capabilities.get(unique_id) + # Make sure we handle ip changes + if not current_entry or host != urlparse(current_entry["location"]).hostname: _LOGGER.debug("Yeelight discovered with %s", response) self._async_discovered_by_ssdp(response) self._host_capabilities[host] = response @@ -541,6 +554,17 @@ class YeelightScanner: self._async_stop_scan() +def update_needs_bg_power_workaround(data): + """Check if a push update needs the bg_power workaround. + + Some devices will push the incorrect state for bg_power. + + To work around this any time we are pushed an update + with bg_power, we force poll state which will be correct. + """ + return "bg_power" in data + + class YeelightDevice: """Represents single Yeelight device.""" @@ -552,7 +576,7 @@ class YeelightDevice: self._bulb_device = bulb self.capabilities = {} self._device_type = None - self._available = False + self._available = True self._initialized = False self._did_first_update = False self._name = None @@ -582,6 +606,11 @@ class YeelightDevice: """Return true is device is available.""" return self._available + @callback + def async_mark_unavailable(self): + """Set unavailable on api call failure due to a network issue.""" + self._available = False + @property def model(self): """Return configured/autodetected device model.""" @@ -604,9 +633,6 @@ class YeelightDevice: @property def is_nightlight_enabled(self) -> bool: """Return true / false if nightlight is currently enabled.""" - if self.bulb is None: - return False - # Only ceiling lights have active_mode, from SDK docs: # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) if self._active_mode is not None: @@ -642,45 +668,24 @@ class YeelightDevice: return self._device_type - async def async_turn_on( - self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None - ): - """Turn on device.""" - try: - await self.bulb.async_turn_on( - duration=duration, light_type=light_type, power_mode=power_mode - ) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to turn the bulb on: %s", ex) - - async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): - """Turn off device.""" - try: - await self.bulb.async_turn_off(duration=duration, light_type=light_type) - except BULB_EXCEPTIONS as ex: - _LOGGER.error( - "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex - ) - async def _async_update_properties(self): """Read new properties from the device.""" - if not self.bulb: - return - try: await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True if not self._initialized: self._initialized = True async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - except BULB_EXCEPTIONS as ex: + except BULB_NETWORK_EXCEPTIONS as ex: if self._available: # just inform once _LOGGER.error( "Unable to update device %s, %s: %s", self._host, self.name, ex ) self._available = False - - return self._available + except BULB_EXCEPTIONS as ex: + _LOGGER.debug( + "Unable to update device %s, %s: %s", self._host, self.name, ex + ) async def async_setup(self): """Fetch capabilities and setup name if available.""" @@ -706,12 +711,18 @@ class YeelightDevice: await self._async_update_properties() async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) + async def _async_forced_update(self, _now): + """Call a forced update.""" + await self.async_update(True) + @callback def async_update_callback(self, data): """Update push from device.""" was_available = self._available self._available = data.get(KEY_CONNECTED, True) - if self._did_first_update and not was_available and self._available: + if update_needs_bg_power_workaround(data) or ( + self._did_first_update and not was_available and self._available + ): # On reconnect the properties may be out of sync # # We need to make sure the DEVICE_INITIALIZED dispatcher is setup @@ -722,7 +733,7 @@ class YeelightDevice: # to be called when async_setup_entry reaches the end of the # function # - asyncio.create_task(self.async_update(True)) + async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update) async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 73bbcdcfe5f..d59e03c965d 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -60,6 +60,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_ip = discovery_info[IP_ADDRESS] return await self._async_handle_discovery() + async def async_step_zeroconf(self, discovery_info): + """Handle discovery from zeroconf.""" + self._discovered_ip = discovery_info["host"] + await self.async_set_unique_id( + "{0:#0{1}x}".format(int(discovery_info["name"][-26:-18]), 18) + ) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered_ip}, reload_on_update=False + ) + return await self._async_handle_discovery() + async def async_step_ssdp(self, discovery_info): """Handle discovery from ssdp.""" self._discovered_ip = urlparse(discovery_info["location"]).hostname diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index e0c21f21fc7..69dde0e75b6 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -34,6 +34,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -50,6 +51,7 @@ from . import ( ATTR_MODE_MUSIC, ATTR_TRANSITIONS, BULB_EXCEPTIONS, + BULB_NETWORK_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH, @@ -242,8 +244,20 @@ def _async_cmd(func): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) + except BULB_NETWORK_EXCEPTIONS as ex: + # A network error happened, the bulb is likely offline now + self.device.async_mark_unavailable() + self.async_write_ha_state() + exc_message = str(ex) or type(ex) + raise HomeAssistantError( + f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}" + ) from ex except BULB_EXCEPTIONS as ex: - _LOGGER.error("Error when calling %s: %s", func, ex) + # The bulb likely responded but had an error + exc_message = str(ex) or type(ex) + raise HomeAssistantError( + f"Error when calling {func.__name__} for bulb {self.device.name} at {self.device.host}: {exc_message}" + ) from ex return _async_wrap @@ -375,7 +389,7 @@ def _async_setup_services(hass: HomeAssistant): _async_set_auto_delay_off_scene, ) platform.async_register_entity_service( - SERVICE_SET_MUSIC_MODE, SERVICE_SCHEMA_SET_MUSIC_MODE, "set_music_mode" + SERVICE_SET_MUSIC_MODE, SERVICE_SCHEMA_SET_MUSIC_MODE, "async_set_music_mode" ) @@ -509,9 +523,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def effect(self): """Return the current effect.""" - if not self.device.is_color_flow_enabled: - return None - return self._effect + return self._effect if self.device.is_color_flow_enabled else None @property def _bulb(self) -> Bulb: @@ -519,9 +531,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def _properties(self) -> dict: - if self._bulb is None: - return {} - return self._bulb.last_properties + return self._bulb.last_properties if self._bulb else {} def _get_property(self, prop, default=None): return self._properties.get(prop, default) @@ -564,83 +574,88 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Update light properties.""" await self.device.async_update() - def set_music_mode(self, music_mode) -> None: + async def async_set_music_mode(self, music_mode) -> None: """Set the music mode on or off.""" - if music_mode: - try: - self._bulb.start_music() - except AssertionError as ex: - _LOGGER.error(ex) - else: - self._bulb.stop_music() + try: + await self._async_set_music_mode(music_mode) + except AssertionError as ex: + _LOGGER.error("Unable to turn on music mode, consider disabling it: %s", ex) + + @_async_cmd + async def _async_set_music_mode(self, music_mode) -> None: + """Set the music mode on or off wrapped with _async_cmd.""" + bulb = self._bulb + method = bulb.stop_music if not music_mode else bulb.start_music + await self.hass.async_add_executor_job(method) @_async_cmd async def async_set_brightness(self, brightness, duration) -> None: """Set bulb brightness.""" - if brightness: - if math.floor(self.brightness) == math.floor(brightness): - _LOGGER.debug("brightness already set to: %s", brightness) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if not brightness: + return + if math.floor(self.brightness) == math.floor(brightness): + _LOGGER.debug("brightness already set to: %s", brightness) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - _LOGGER.debug("Setting brightness: %s", brightness) - await self._bulb.async_set_brightness( - brightness / 255 * 100, duration=duration, light_type=self.light_type - ) + _LOGGER.debug("Setting brightness: %s", brightness) + await self._bulb.async_set_brightness( + brightness / 255 * 100, duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_hs(self, hs_color, duration) -> None: """Set bulb's color.""" - if hs_color and COLOR_MODE_HS in self.supported_color_modes: - if self.color_mode == COLOR_MODE_HS and self.hs_color == hs_color: - _LOGGER.debug("HS already set to: %s", hs_color) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if not hs_color or COLOR_MODE_HS not in self.supported_color_modes: + return + if self.color_mode == COLOR_MODE_HS and self.hs_color == hs_color: + _LOGGER.debug("HS already set to: %s", hs_color) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - _LOGGER.debug("Setting HS: %s", hs_color) - await self._bulb.async_set_hsv( - hs_color[0], hs_color[1], duration=duration, light_type=self.light_type - ) + _LOGGER.debug("Setting HS: %s", hs_color) + await self._bulb.async_set_hsv( + hs_color[0], hs_color[1], duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_rgb(self, rgb, duration) -> None: """Set bulb's color.""" - if rgb and COLOR_MODE_RGB in self.supported_color_modes: - if self.color_mode == COLOR_MODE_RGB and self.rgb_color == rgb: - _LOGGER.debug("RGB already set to: %s", rgb) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if not rgb or COLOR_MODE_RGB not in self.supported_color_modes: + return + if self.color_mode == COLOR_MODE_RGB and self.rgb_color == rgb: + _LOGGER.debug("RGB already set to: %s", rgb) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - _LOGGER.debug("Setting RGB: %s", rgb) - await self._bulb.async_set_rgb( - *rgb, duration=duration, light_type=self.light_type - ) + _LOGGER.debug("Setting RGB: %s", rgb) + await self._bulb.async_set_rgb( + *rgb, duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_colortemp(self, colortemp, duration) -> None: """Set bulb's color temperature.""" - if colortemp and COLOR_MODE_COLOR_TEMP in self.supported_color_modes: - temp_in_k = mired_to_kelvin(colortemp) + if not colortemp or COLOR_MODE_COLOR_TEMP not in self.supported_color_modes: + return + temp_in_k = mired_to_kelvin(colortemp) - if ( - self.color_mode == COLOR_MODE_COLOR_TEMP - and self.color_temp == colortemp - ): - _LOGGER.debug("Color temp already set to: %s", temp_in_k) - # Already set, and since we get pushed updates - # we avoid setting it again to ensure we do not - # hit the rate limit - return + if self.color_mode == COLOR_MODE_COLOR_TEMP and self.color_temp == colortemp: + _LOGGER.debug("Color temp already set to: %s", temp_in_k) + # Already set, and since we get pushed updates + # we avoid setting it again to ensure we do not + # hit the rate limit + return - await self._bulb.async_set_color_temp( - temp_in_k, duration=duration, light_type=self.light_type - ) + await self._bulb.async_set_color_temp( + temp_in_k, duration=duration, light_type=self.light_type + ) @_async_cmd async def async_set_default(self) -> None: @@ -650,37 +665,33 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @_async_cmd async def async_set_flash(self, flash) -> None: """Activate flash.""" - if flash: - if int(self._bulb.last_properties["color_mode"]) != 1: - _LOGGER.error("Flash supported currently only in RGB mode") - return + if not flash: + return + if int(self._bulb.last_properties["color_mode"]) != 1: + _LOGGER.error("Flash supported currently only in RGB mode") + return - transition = int(self.config[CONF_TRANSITION]) - if flash == FLASH_LONG: - count = 1 - duration = transition * 5 - if flash == FLASH_SHORT: - count = 1 - duration = transition * 2 + transition = int(self.config[CONF_TRANSITION]) + if flash == FLASH_LONG: + count = 1 + duration = transition * 5 + if flash == FLASH_SHORT: + count = 1 + duration = transition * 2 - red, green, blue = color_util.color_hs_to_RGB(*self.hs_color) + red, green, blue = color_util.color_hs_to_RGB(*self.hs_color) - transitions = [] - transitions.append( - RGBTransition(255, 0, 0, brightness=10, duration=duration) - ) - transitions.append(SleepTransition(duration=transition)) - transitions.append( - RGBTransition( - red, green, blue, brightness=self.brightness, duration=duration - ) + transitions = [] + transitions.append(RGBTransition(255, 0, 0, brightness=10, duration=duration)) + transitions.append(SleepTransition(duration=transition)) + transitions.append( + RGBTransition( + red, green, blue, brightness=self.brightness, duration=duration ) + ) - flow = Flow(count=count, transitions=transitions) - try: - await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set flash: %s", ex) + flow = Flow(count=count, transitions=transitions) + await self._bulb.async_start_flow(flow, light_type=self.light_type) @_async_cmd async def async_set_effect(self, effect) -> None: @@ -707,11 +718,17 @@ class YeelightGenericLight(YeelightEntity, LightEntity): else: return - try: - await self._bulb.async_start_flow(flow, light_type=self.light_type) - self._effect = effect - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set effect: %s", ex) + await self._bulb.async_start_flow(flow, light_type=self.light_type) + self._effect = effect + + @_async_cmd + async def _async_turn_on(self, duration) -> None: + """Turn on the bulb for with a transition duration wrapped with _async_cmd.""" + await self._bulb.async_turn_on( + duration=duration, + light_type=self.light_type, + power_mode=self._turn_on_power_mode, + ) async def async_turn_on(self, **kwargs) -> None: """Turn the bulb on.""" @@ -727,45 +744,26 @@ class YeelightGenericLight(YeelightEntity, LightEntity): duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s if not self.is_on: - await self.device.async_turn_on( - duration=duration, - light_type=self.light_type, - power_mode=self._turn_on_power_mode, - ) + await self._async_turn_on(duration) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: - try: - await self.hass.async_add_executor_job( - self.set_music_mode, self.config[CONF_MODE_MUSIC] - ) - except BULB_EXCEPTIONS as ex: - _LOGGER.error( - "Unable to turn on music mode, consider disabling it: %s", ex - ) + await self.async_set_music_mode(True) - try: - # values checked for none in methods - await self.async_set_hs(hs_color, duration) - await self.async_set_rgb(rgb, duration) - await self.async_set_colortemp(colortemp, duration) - await self.async_set_brightness(brightness, duration) - await self.async_set_flash(flash) - await self.async_set_effect(effect) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set bulb properties: %s", ex) - return + await self.async_set_hs(hs_color, duration) + await self.async_set_rgb(rgb, duration) + await self.async_set_colortemp(colortemp, duration) + await self.async_set_brightness(brightness, duration) + await self.async_set_flash(flash) + await self.async_set_effect(effect) # save the current state if we had a manual change. if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): - try: - await self.async_set_default() - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set the defaults: %s", ex) - return + await self.async_set_default() - # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh - if not self.is_on: - await self.device.async_update(True) + @_async_cmd + async def _async_turn_off(self, duration) -> None: + """Turn off with a given transition duration wrapped with _async_cmd.""" + await self._bulb.async_turn_off(duration=duration, light_type=self.light_type) async def async_turn_off(self, **kwargs) -> None: """Turn off.""" @@ -776,39 +774,27 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - await self.device.async_turn_off(duration=duration, light_type=self.light_type) - # Some devices will not send back the off state so we need to force a refresh - if self.is_on: - await self.device.async_update(True) + await self._async_turn_off(duration) + @_async_cmd async def async_set_mode(self, mode: str): """Set a power mode.""" - try: - await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set the power mode: %s", ex) + await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) + @_async_cmd async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): """Start flow.""" - try: - flow = Flow( - count=count, action=Flow.actions[action], transitions=transitions - ) - - await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set effect: %s", ex) + flow = Flow(count=count, action=Flow.actions[action], transitions=transitions) + await self._bulb.async_start_flow(flow, light_type=self.light_type) + @_async_cmd async def async_set_scene(self, scene_class, *args): """ Set the light directly to the specified state. If the light is off, it will first be turned on. """ - try: - await self._bulb.async_set_scene(scene_class, *args) - except BULB_EXCEPTIONS as ex: - _LOGGER.error("Unable to set scene: %s", ex) + await self._bulb.async_set_scene(scene_class, *args) class YeelightColorLightSupport(YeelightGenericLight): @@ -853,10 +839,8 @@ class YeelightNightLightSupport: return PowerMode.NORMAL -class YeelightColorLightWithoutNightlightSwitch( - YeelightColorLightSupport, YeelightGenericLight -): - """Representation of a Color Yeelight light.""" +class YeelightWithoutNightlightSwitchMixIn: + """A mix-in for yeelights without a nightlight switch.""" @property def _brightness_property(self): @@ -864,9 +848,25 @@ class YeelightColorLightWithoutNightlightSwitch( # want to "current_brightness" since it will check # "bg_power" and main light could still be on if self.device.is_nightlight_enabled: - return "current_brightness" + return "nl_br" return super()._brightness_property + @property + def color_temp(self) -> int: + """Return the color temperature.""" + if self.device.is_nightlight_enabled: + # Enabling the nightlight locks the colortemp to max + return self._max_mireds + return super().color_temp + + +class YeelightColorLightWithoutNightlightSwitch( + YeelightColorLightSupport, + YeelightWithoutNightlightSwitchMixIn, + YeelightGenericLight, +): + """Representation of a Color Yeelight light.""" + class YeelightColorLightWithNightlightSwitch( YeelightNightLightSupport, YeelightColorLightSupport, YeelightGenericLight @@ -883,19 +883,12 @@ class YeelightColorLightWithNightlightSwitch( class YeelightWhiteTempWithoutNightlightSwitch( - YeelightWhiteTempLightSupport, YeelightGenericLight + YeelightWhiteTempLightSupport, + YeelightWithoutNightlightSwitchMixIn, + YeelightGenericLight, ): """White temp light, when nightlight switch is not set to light.""" - @property - def _brightness_property(self): - # If the nightlight is not active, we do not - # want to "current_brightness" since it will check - # "bg_power" and main light could still be on - if self.device.is_nightlight_enabled: - return "current_brightness" - return super()._brightness_property - class YeelightWithNightLight( YeelightNightLightSupport, YeelightWhiteTempLightSupport, YeelightGenericLight @@ -914,6 +907,9 @@ class YeelightWithNightLight( class YeelightNightLightMode(YeelightGenericLight): """Representation of a Yeelight when in nightlight mode.""" + _attr_color_mode = COLOR_MODE_BRIGHTNESS + _attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + @property def unique_id(self) -> str: """Return a unique ID.""" @@ -944,8 +940,9 @@ class YeelightNightLightMode(YeelightGenericLight): return PowerMode.MOONLIGHT @property - def _predefined_effects(self): - return YEELIGHT_TEMP_ONLY_EFFECT_LIST + def supported_features(self): + """Flag no supported features.""" + return 0 class YeelightNightLightModeWithAmbientSupport(YeelightNightLightMode): @@ -965,11 +962,6 @@ class YeelightNightLightModeWithoutBrightnessControl(YeelightNightLightMode): _attr_color_mode = COLOR_MODE_ONOFF _attr_supported_color_modes = {COLOR_MODE_ONOFF} - @property - def supported_features(self): - """Flag no supported features.""" - return 0 - class YeelightWithAmbientWithoutNightlight(YeelightWhiteTempWithoutNightlightSwitch): """Representation of a Yeelight which has ambilight support. diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 0a4b5d4499f..561606f5509 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,15 +2,18 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"], + "requirements": ["yeelight==0.7.6", "async-upnp-client==0.22.5"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], "quality_scale": "platinum", "iot_class": "local_push", - "dhcp": [{ - "hostname": "yeelink-*" - }], + "dhcp": [ + { + "hostname": "yeelink-*" + } + ], + "zeroconf": [{ "type": "_miio._udp.local.", "name": "yeelink-*" }], "homekit": { "models": ["YL*"] } diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index a0ce26550c8..73868b6c571 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -30,7 +30,7 @@ "init": { "description": "If you leave model empty, it will be automatically detected.", "data": { - "model": "Model (Optional)", + "model": "Model (optional)", "transition": "Transition Time (ms)", "use_music_mode": "Enable Music Mode", "save_on_change": "Save Status On Change", diff --git a/homeassistant/components/yeelight/translations/cs.json b/homeassistant/components/yeelight/translations/cs.json index 8bab9bd19b1..adc42efddb7 100644 --- a/homeassistant/components/yeelight/translations/cs.json +++ b/homeassistant/components/yeelight/translations/cs.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" }, + "flow_title": "{model} {id} ({host})", "step": { "pick_device": { "data": { diff --git a/homeassistant/components/yeelight/translations/en.json b/homeassistant/components/yeelight/translations/en.json index 3ed5bbe5515..4ed9440aa8f 100644 --- a/homeassistant/components/yeelight/translations/en.json +++ b/homeassistant/components/yeelight/translations/en.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (Optional)", + "model": "Model (optional)", "nightlight_switch": "Use Nightlight Switch", "save_on_change": "Save Status On Change", "transition": "Transition Time (ms)", diff --git a/homeassistant/components/yeelight/translations/es.json b/homeassistant/components/yeelight/translations/es.json index 044a10c695d..c58d863fa13 100644 --- a/homeassistant/components/yeelight/translations/es.json +++ b/homeassistant/components/yeelight/translations/es.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "No se pudo conectar" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u00bfQuieres configurar {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/hu.json b/homeassistant/components/yeelight/translations/hu.json index 26dc6cb5ba1..6cf10422c28 100644 --- a/homeassistant/components/yeelight/translations/hu.json +++ b/homeassistant/components/yeelight/translations/hu.json @@ -7,10 +7,10 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { - "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a {model} ( {host} ) szolg\u00e1ltat\u00e1st?" + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a {model} ({host}) szolg\u00e1ltat\u00e1st?" }, "pick_device": { "data": { @@ -19,9 +19,9 @@ }, "user": { "data": { - "host": "Hoszt" + "host": "C\u00edm" }, - "description": "Ha a gazdag\u00e9pet \u00fcresen hagyja, felder\u00edt\u00e9sre ker\u00fcl automatikusan." + "description": "Ha nem ad meg c\u00edmet, akkor az eszk\u00f6z\u00f6k keres\u00e9se a felder\u00edt\u00e9ssel t\u00f6rt\u00e9nik." } } }, diff --git a/homeassistant/components/yeelight/translations/id.json b/homeassistant/components/yeelight/translations/id.json index 3b2f0273ae3..d9795662689 100644 --- a/homeassistant/components/yeelight/translations/id.json +++ b/homeassistant/components/yeelight/translations/id.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Ingin menyiapkan {model} ({host})?" @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (Opsional)", + "model": "Model (opsional)", "nightlight_switch": "Gunakan Sakelar Lampu Malam", "save_on_change": "Simpan Status Saat Berubah", "transition": "Waktu Transisi (milidetik)", diff --git a/homeassistant/components/yeelight/translations/it.json b/homeassistant/components/yeelight/translations/it.json index 1a139dcd8b4..4036b6d6338 100644 --- a/homeassistant/components/yeelight/translations/it.json +++ b/homeassistant/components/yeelight/translations/it.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Impossibile connettersi" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Vuoi configurare {model} ({host})?" diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 1ea7bc67ba9..04a66d507ef 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -3,7 +3,7 @@ "name": "YouLess", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/youless", - "requirements": ["youless-api==0.12"], + "requirements": ["youless-api==0.13"], "codeowners": ["@gjong"], "iot_class": "local_polling" } diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 0b081ab15a2..24983bb567b 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -47,7 +47,7 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "low"), DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), - ExtraMeterSensor(coordinator, device, "usage"), + ExtraMeterPowerSensor(coordinator, device, "usage"), ] ) @@ -191,7 +191,8 @@ class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_device_class = DEVICE_CLASS_POWER + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -210,3 +211,29 @@ class ExtraMeterSensor(YoulessBaseSensor): return None return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) + + +class ExtraMeterPowerSensor(YoulessBaseSensor): + """The Youless extra meter power value sensor (s0).""" + + _attr_native_unit_of_measurement = POWER_WATT + _attr_device_class = DEVICE_CLASS_POWER + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator, device: str, dev_type: str + ) -> None: + """Instantiate an extra meter power 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/translations/es.json b/homeassistant/components/youless/translations/es.json index 72a56cc5608..77837bb25ce 100644 --- a/homeassistant/components/youless/translations/es.json +++ b/homeassistant/components/youless/translations/es.json @@ -6,7 +6,7 @@ "step": { "user": { "data": { - "host": "Anfitri\u00f3n", + "host": "Host", "name": "Nombre" } } diff --git a/homeassistant/components/youless/translations/hu.json b/homeassistant/components/youless/translations/hu.json index 21c7a7ebe4b..31913b7fa6f 100644 --- a/homeassistant/components/youless/translations/hu.json +++ b/homeassistant/components/youless/translations/hu.json @@ -6,7 +6,7 @@ "step": { "user": { "data": { - "host": "H\u00e1zigazda", + "host": "C\u00edm", "name": "N\u00e9v" } } diff --git a/homeassistant/components/youless/translations/id.json b/homeassistant/components/youless/translations/id.json new file mode 100644 index 00000000000..fd6c2bc2491 --- /dev/null +++ b/homeassistant/components/youless/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nama" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 17cfb9d05de..4afb0a3c24d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,9 +5,10 @@ import asyncio from collections.abc import Coroutine from contextlib import suppress import fnmatch -from ipaddress import IPv6Address, ip_address +from ipaddress import IPv4Address, IPv6Address, ip_address import logging import socket +import sys from typing import Any, TypedDict, cast import voluptuous as vol @@ -131,18 +132,31 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZero return aio_zc +@callback +def _async_zc_has_functional_dual_stack() -> bool: + """Return true for platforms that not support IP_ADD_MEMBERSHIP on an AF_INET6 socket. + + Zeroconf only supports a single listen socket at this time. + """ + return not sys.platform.startswith("freebsd") and not sys.platform.startswith( + "darwin" + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {} + zc_args: dict = {"ip_version": IPVersion.V4Only} adapters = await network.async_get_adapters(hass) - 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 + ipv6 = False + if _async_zc_has_functional_dual_stack(): + if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): + ipv6 = True + zc_args["ip_version"] = IPVersion.All + elif not any(adapter["enabled"] and adapter["ipv4"] for adapter in adapters): + zc_args["ip_version"] = IPVersion.V6Only + ipv6 = True if not ipv6 and network.async_only_default_interface_enabled(adapters): zc_args["interfaces"] = InterfaceChoice.Default @@ -152,6 +166,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) + and not ( + isinstance(source_ip, IPv6Address) + and zc_args["ip_version"] == IPVersion.V4Only + ) + and not ( + isinstance(source_ip, IPv4Address) + and zc_args["ip_version"] == IPVersion.V6Only + ) ] aio_zc = await _async_get_instance(hass, **zc_args) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6ed4c8d09dd..e38a8d92a94 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.36.2"], + "requirements": ["zeroconf==0.36.7"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zerproc/translations/fr.json b/homeassistant/components/zerproc/translations/fr.json index 80ae6cb0ec8..e9ae4e0b644 100644 --- a/homeassistant/components/zerproc/translations/fr.json +++ b/homeassistant/components/zerproc/translations/fr.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "no_devices_found": "Pas d'appareil trouv\u00e9 sur le r\u00e9seau", + "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 demmarer la configuration ?" + "description": "Voulez-vous commencer la configuration ?" } } } diff --git a/homeassistant/components/zerproc/translations/hu.json b/homeassistant/components/zerproc/translations/hu.json index 6c61530acbe..a56ebbfc906 100644 --- a/homeassistant/components/zerproc/translations/hu.json +++ b/homeassistant/components/zerproc/translations/hu.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" } } } diff --git a/homeassistant/components/zerproc/translations/nl.json b/homeassistant/components/zerproc/translations/nl.json index d11896014fd..0671f0b3674 100644 --- a/homeassistant/components/zerproc/translations/nl.json +++ b/homeassistant/components/zerproc/translations/nl.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Wil je beginnen met instellen?" + "description": "Wilt u beginnen met instellen?" } } } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e5b8c0936fd..d6578be775f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -186,5 +186,14 @@ async def async_migrate_entry( config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, data=data) + if config_entry.version == 2: + data = {**config_entry.data} + + if data[CONF_RADIO_TYPE] == "ti_cc": + data[CONF_RADIO_TYPE] = "znp" + + config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index a0d8abc1233..e6f03a8a848 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core import discovery from .core.const import ( CHANNEL_ACCELEROMETER, + CHANNEL_BINARY_INPUT, CHANNEL_OCCUPANCY, CHANNEL_ON_OFF, CHANNEL_ZONE, @@ -136,6 +137,13 @@ class Opening(BinarySensor): DEVICE_CLASS = DEVICE_CLASS_OPENING +@STRICT_MATCH(channel_names=CHANNEL_BINARY_INPUT) +class BinaryInput(BinarySensor): + """ZHA BinarySensor.""" + + SENSOR_ATTR = "present_value" + + @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, manufacturers="IKEA of Sweden", diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index b98878da776..2b867366453 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -31,7 +31,7 @@ DECONZ_DOMAIN = "deconz" class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 2 + VERSION = 3 def __init__(self): """Initialize flow instance.""" @@ -145,9 +145,9 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: auto_detected_data = await detect_radios(self._device_path) if auto_detected_data is None: - # This probably will not happen how they have - # have very specific usb matching, but there could - # be a problem with the device + # This path probably will not happen now that we have + # more precise USB matching unless there is a problem + # with the device return self.async_abort(reason="usb_probe_failed") return self.async_create_entry( title=self._title, diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index e38e9e992da..d297b5187c0 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio from enum import Enum -from functools import wraps +from functools import partialmethod, wraps import logging from typing import Any import zigpy.exceptions +from zigpy.zcl.foundation import Status from homeassistant.const import ATTR_COMMAND from homeassistant.core import callback @@ -23,13 +24,15 @@ from ..const import ( ATTR_UNIQUE_ID, ATTR_VALUE, CHANNEL_ZDO, + REPORT_CONFIG_ATTR_PER_REQ, SIGNAL_ATTR_UPDATED, ZHA_CHANNEL_MSG, ZHA_CHANNEL_MSG_BIND, ZHA_CHANNEL_MSG_CFG_RPT, ZHA_CHANNEL_MSG_DATA, + ZHA_CHANNEL_READS_PER_REQ, ) -from ..helpers import LogMixin, safe_read +from ..helpers import LogMixin, retryable_req, safe_read _LOGGER = logging.getLogger(__name__) @@ -87,9 +90,14 @@ class ChannelStatus(Enum): class ZigbeeChannel(LogMixin): """Base channel for a Zigbee cluster.""" - REPORT_CONFIG = () + REPORT_CONFIG: tuple[dict[int | str, tuple[int, int, int | float]]] = () BIND: bool = True + # Dict of attributes to read on channel initialization. + # Dict keys -- attribute ID or names, with bool value indicating whether a cached + # attribute read is acceptable. + ZCL_INIT_ATTRS: dict[int | str, bool] = {} + def __init__( self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: @@ -101,9 +109,8 @@ class ZigbeeChannel(LogMixin): self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" unique_id = ch_pool.unique_id.replace("-", ":") self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" - self._report_config = self.REPORT_CONFIG - if not hasattr(self, "_value_attribute") and len(self._report_config) > 0: - attr = self._report_config[0].get("attr") + if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: + attr = self.REPORT_CONFIG[0].get("attr") if isinstance(attr, str): self.value_attribute = self.cluster.attridx.get(attr) else: @@ -141,6 +148,10 @@ class ZigbeeChannel(LogMixin): """Return the status of the channel.""" return self._status + def __hash__(self) -> int: + """Make this a hashable.""" + return hash(self._unique_id) + @callback def async_send_signal(self, signal: str, *args: Any) -> None: """Send a signal through hass dispatcher.""" @@ -195,42 +206,42 @@ class ZigbeeChannel(LogMixin): if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: kwargs["manufacturer"] = self._ch_pool.manufacturer_code - for report in self._report_config: - attr = report["attr"] + for attr_report in self.REPORT_CONFIG: + attr, config = attr_report["attr"], attr_report["config"] attr_name = self.cluster.attributes.get(attr, [attr])[0] - min_report_int, max_report_int, reportable_change = report["config"] event_data[attr_name] = { - "min": min_report_int, - "max": max_report_int, + "min": config[0], + "max": config[1], "id": attr, "name": attr_name, - "change": reportable_change, + "change": config[2], + "success": False, } + to_configure = [*self.REPORT_CONFIG] + chunk, rest = ( + to_configure[:REPORT_CONFIG_ATTR_PER_REQ], + to_configure[REPORT_CONFIG_ATTR_PER_REQ:], + ) + while chunk: + reports = {rec["attr"]: rec["config"] for rec in chunk} try: - res = await self.cluster.configure_reporting( - attr, min_report_int, max_report_int, reportable_change, **kwargs - ) - self.debug( - "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - attr_name, - self.cluster.ep_attribute, - min_report_int, - max_report_int, - reportable_change, - res, - ) - event_data[attr_name]["success"] = ( - res[0][0].status == 0 or res[0][0].status == 134 - ) + res = await self.cluster.configure_reporting_multiple(reports, **kwargs) + self._configure_reporting_status(reports, res[0]) + # if we get a response, then it's a success + for attr_stat in event_data.values(): + attr_stat["success"] = True except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( - "failed to set reporting for '%s' attr on '%s' cluster: %s", - attr_name, + "failed to set reporting on '%s' cluster for: %s", self.cluster.ep_attribute, str(ex), ) - event_data[attr_name]["success"] = False + break + chunk, rest = ( + rest[:REPORT_CONFIG_ATTR_PER_REQ], + rest[REPORT_CONFIG_ATTR_PER_REQ:], + ) async_dispatcher_send( self._ch_pool.hass, @@ -245,6 +256,46 @@ class ZigbeeChannel(LogMixin): }, ) + def _configure_reporting_status( + self, attrs: dict[int | str, tuple], res: list | tuple + ) -> None: + """Parse configure reporting result.""" + if not isinstance(res, list): + # assume default response + self.debug( + "attr reporting for '%s' on '%s': %s", + attrs, + self.name, + res, + ) + return + if res[0].status == Status.SUCCESS and len(res) == 1: + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster: %s", + attrs, + self.name, + res, + ) + return + + failed = [ + self.cluster.attributes.get(r.attrid, [r.attrid])[0] + for r in res + if r.status != Status.SUCCESS + ] + attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster", + attrs - set(failed), + self.name, + ) + self.debug( + "Failed to configure reporting for '%s' on '%s' cluster: %s", + failed, + self.name, + res, + ) + async def async_configure(self) -> None: """Set cluster binding and attribute reporting.""" if not self._ch_pool.skip_configuration: @@ -260,6 +311,7 @@ class ZigbeeChannel(LogMixin): self.debug("skipping channel configuration") self._status = ChannelStatus.CONFIGURED + @retryable_req(delays=(1, 1, 3)) async def async_initialize(self, from_cache: bool) -> None: """Initialize channel.""" if not from_cache and self._ch_pool.skip_configuration: @@ -267,9 +319,14 @@ class ZigbeeChannel(LogMixin): return self.debug("initializing channel: from_cache: %s", from_cache) - attributes = [cfg["attr"] for cfg in self._report_config] - if attributes: - await self.get_attributes(attributes, from_cache=from_cache) + cached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if cached] + uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] + uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) + + if cached: + await self._get_attributes(True, cached, from_cache=True) + if uncached: + await self._get_attributes(True, uncached, from_cache=from_cache) ch_specific_init = getattr(self, "async_initialize_channel_specific", None) if ch_specific_init: @@ -326,28 +383,43 @@ class ZigbeeChannel(LogMixin): ) return result.get(attribute) - async def get_attributes(self, attributes, from_cache=True): + async def _get_attributes( + self, + raise_exceptions: bool, + attributes: list[int | str], + from_cache: bool = True, + ) -> dict[int | str, Any]: """Get the values for a list of attributes.""" manufacturer = None manufacturer_code = self._ch_pool.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code - try: - result, _ = await self.cluster.read_attributes( - attributes, - allow_cache=from_cache, - only_cache=from_cache and not self._ch_pool.is_mains_powered, - manufacturer=manufacturer, - ) - return result - except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: - self.debug( - "failed to get attributes '%s' on '%s' cluster: %s", - attributes, - self.cluster.ep_attribute, - str(ex), - ) - return {} + chunk = attributes[:ZHA_CHANNEL_READS_PER_REQ] + rest = attributes[ZHA_CHANNEL_READS_PER_REQ:] + result = {} + while chunk: + try: + read, _ = await self.cluster.read_attributes( + attributes, + allow_cache=from_cache, + only_cache=from_cache and not self._ch_pool.is_mains_powered, + manufacturer=manufacturer, + ) + result.update(read) + except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: + self.debug( + "failed to get attributes '%s' on '%s' cluster: %s", + attributes, + self.cluster.ep_attribute, + str(ex), + ) + if raise_exceptions: + raise + chunk = rest[:ZHA_CHANNEL_READS_PER_REQ] + rest = rest[ZHA_CHANNEL_READS_PER_REQ:] + return result + + get_attributes = partialmethod(_get_attributes, False) def log(self, level, msg, *args): """Log a message.""" diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 5ca8c9fd4ba..d0216436ba2 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -23,7 +23,6 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from ..helpers import retryable_req from .base import ClientChannel, ZigbeeChannel, parse_and_log_command @@ -44,7 +43,16 @@ class AnalogInput(ZigbeeChannel): class AnalogOutput(ZigbeeChannel): """Analog Output channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ({"attr": "present_value", "config": REPORT_CONFIG_DEFAULT},) + ZCL_INIT_ATTRS = { + "min_present_value": True, + "max_present_value": True, + "resolution": True, + "relinquish_default": True, + "description": True, + "engineering_units": True, + "application_type": True, + } @property def present_value(self) -> float | None: @@ -99,25 +107,6 @@ class AnalogOutput(ZigbeeChannel): return True return False - @retryable_req(delays=(1, 1, 3)) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - return self.fetch_config(from_cache) - - async def fetch_config(self, from_cache: bool) -> None: - """Get the channel configuration.""" - attributes = [ - "min_present_value", - "max_present_value", - "resolution", - "relinquish_default", - "description", - "engineering_units", - "application_type", - ] - # just populates the cache, if not already done - await self.get_attributes(attributes, from_cache=from_cache) - @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) class AnalogValue(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 583cfb105bd..fc00db4f2d4 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -1,8 +1,6 @@ """Home automation channels module for Zigbee Home Automation.""" from __future__ import annotations -from collections.abc import Coroutine - from zigpy.zcl.clusters import homeautomation from .. import registries @@ -49,6 +47,12 @@ class ElectricalMeasurementChannel(ZigbeeChannel): CHANNEL_NAME = CHANNEL_ELECTRICAL_MEASUREMENT REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},) + ZCL_INIT_ATTRS = { + "ac_power_divisor": True, + "power_divisor": True, + "ac_power_multiplier": True, + "power_multiplier": True, + } async def async_update(self): """Retrieve latest state.""" @@ -64,19 +68,6 @@ class ElectricalMeasurementChannel(ZigbeeChannel): result, ) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel specific attributes.""" - - return self.get_attributes( - [ - "ac_power_divisor", - "power_divisor", - "ac_power_multiplier", - "power_multiplier", - ], - from_cache=True, - ) - @property def divisor(self) -> int | None: """Return active power divisor.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 6b0cd9e5e28..726d9f15376 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -6,7 +6,6 @@ https://home-assistant.io/integrations/zha/ """ from __future__ import annotations -import asyncio from collections import namedtuple from typing import Any @@ -16,14 +15,13 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from ..helpers import retryable_req from .base import ZigbeeChannel AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") @@ -44,12 +42,18 @@ class FanChannel(ZigbeeChannel): _value_attribute = 0 REPORT_CONFIG = ({"attr": "fan_mode", "config": REPORT_CONFIG_OP},) + ZCL_INIT_ATTRS = {"fan_mode_sequence": True} @property def fan_mode(self) -> int | None: """Return current fan mode.""" return self.cluster.get("fan_mode") + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get("fan_mode_sequence") + async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" @@ -85,174 +89,142 @@ class Pump(ZigbeeChannel): class ThermostatChannel(ZigbeeChannel): """Thermostat channel.""" - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: - """Init Thermostat channel instance.""" - super().__init__(cluster, ch_pool) - self._init_attrs = { - "abs_min_heat_setpoint_limit": True, - "abs_max_heat_setpoint_limit": True, - "abs_min_cool_setpoint_limit": True, - "abs_max_cool_setpoint_limit": True, - "ctrl_seqe_of_oper": False, - "local_temp": False, - "max_cool_setpoint_limit": True, - "max_heat_setpoint_limit": True, - "min_cool_setpoint_limit": True, - "min_heat_setpoint_limit": True, - "occupancy": False, - "occupied_cooling_setpoint": False, - "occupied_heating_setpoint": False, - "pi_cooling_demand": False, - "pi_heating_demand": False, - "running_mode": False, - "running_state": False, - "system_mode": False, - "unoccupied_heating_setpoint": False, - "unoccupied_cooling_setpoint": False, - } - self._abs_max_cool_setpoint_limit = 3200 # 32C - self._abs_min_cool_setpoint_limit = 1600 # 16C - self._ctrl_seqe_of_oper = 0xFF - self._abs_max_heat_setpoint_limit = 3000 # 30C - self._abs_min_heat_setpoint_limit = 700 # 7C - self._running_mode = None - self._max_cool_setpoint_limit = None - self._max_heat_setpoint_limit = None - self._min_cool_setpoint_limit = None - self._min_heat_setpoint_limit = None - self._local_temp = None - self._occupancy = None - self._occupied_cooling_setpoint = None - self._occupied_heating_setpoint = None - self._pi_cooling_demand = None - self._pi_heating_demand = None - self._running_state = None - self._system_mode = None - self._unoccupied_cooling_setpoint = None - self._unoccupied_heating_setpoint = None - self._report_config = [ - {"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, - {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - ] + REPORT_CONFIG = ( + {"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, + {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + ) + ZCL_INIT_ATTRS: dict[int | str, bool] = { + "abs_min_heat_setpoint_limit": True, + "abs_max_heat_setpoint_limit": True, + "abs_min_cool_setpoint_limit": True, + "abs_max_cool_setpoint_limit": True, + "ctrl_seqe_of_oper": False, + "max_cool_setpoint_limit": True, + "max_heat_setpoint_limit": True, + "min_cool_setpoint_limit": True, + "min_heat_setpoint_limit": True, + } @property def abs_max_cool_setpoint_limit(self) -> int: """Absolute maximum cooling setpoint.""" - return self._abs_max_cool_setpoint_limit + return self.cluster.get("abs_max_cool_setpoint_limit", 3200) @property def abs_min_cool_setpoint_limit(self) -> int: """Absolute minimum cooling setpoint.""" - return self._abs_min_cool_setpoint_limit + return self.cluster.get("abs_min_cool_setpoint_limit", 1600) @property def abs_max_heat_setpoint_limit(self) -> int: """Absolute maximum heating setpoint.""" - return self._abs_max_heat_setpoint_limit + return self.cluster.get("abs_max_heat_setpoint_limit", 3000) @property def abs_min_heat_setpoint_limit(self) -> int: """Absolute minimum heating setpoint.""" - return self._abs_min_heat_setpoint_limit + return self.cluster.get("abs_min_heat_setpoint_limit", 700) @property def ctrl_seqe_of_oper(self) -> int: """Control Sequence of operations attribute.""" - return self._ctrl_seqe_of_oper + return self.cluster.get("ctrl_seqe_of_oper", 0xFF) @property def max_cool_setpoint_limit(self) -> int: """Maximum cooling setpoint.""" - if self._max_cool_setpoint_limit is None: + sp_limit = self.cluster.get("max_cool_setpoint_limit") + if sp_limit is None: return self.abs_max_cool_setpoint_limit - return self._max_cool_setpoint_limit + return sp_limit @property def min_cool_setpoint_limit(self) -> int: """Minimum cooling setpoint.""" - if self._min_cool_setpoint_limit is None: + sp_limit = self.cluster.get("min_cool_setpoint_limit") + if sp_limit is None: return self.abs_min_cool_setpoint_limit - return self._min_cool_setpoint_limit + return sp_limit @property def max_heat_setpoint_limit(self) -> int: """Maximum heating setpoint.""" - if self._max_heat_setpoint_limit is None: + sp_limit = self.cluster.get("max_heat_setpoint_limit") + if sp_limit is None: return self.abs_max_heat_setpoint_limit - return self._max_heat_setpoint_limit + return sp_limit @property def min_heat_setpoint_limit(self) -> int: """Minimum heating setpoint.""" - if self._min_heat_setpoint_limit is None: + sp_limit = self.cluster.get("min_heat_setpoint_limit") + if sp_limit is None: return self.abs_min_heat_setpoint_limit - return self._min_heat_setpoint_limit + return sp_limit @property def local_temp(self) -> int | None: """Thermostat temperature.""" - return self._local_temp + return self.cluster.get("local_temp") @property def occupancy(self) -> int | None: """Is occupancy detected.""" - return self._occupancy + return self.cluster.get("occupancy") @property def occupied_cooling_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self._occupied_cooling_setpoint + return self.cluster.get("occupied_cooling_setpoint") @property def occupied_heating_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self._occupied_heating_setpoint + return self.cluster.get("occupied_heating_setpoint") @property def pi_cooling_demand(self) -> int: """Cooling demand.""" - return self._pi_cooling_demand + return self.cluster.get("pi_cooling_demand") @property def pi_heating_demand(self) -> int: """Heating demand.""" - return self._pi_heating_demand + return self.cluster.get("pi_heating_demand") @property def running_mode(self) -> int | None: """Thermostat running mode.""" - return self._running_mode + return self.cluster.get("running_mode") @property def running_state(self) -> int | None: """Thermostat running state, state of heat, cool, fan relays.""" - return self._running_state + return self.cluster.get("running_state") @property def system_mode(self) -> int | None: """System mode.""" - return self._system_mode + return self.cluster.get("system_mode") @property def unoccupied_cooling_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self._unoccupied_cooling_setpoint + return self.cluster.get("unoccupied_cooling_setpoint") @property def unoccupied_heating_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self._unoccupied_heating_setpoint + return self.cluster.get("unoccupied_heating_setpoint") @callback def attribute_updated(self, attrid, value): @@ -261,112 +233,17 @@ class ThermostatChannel(ZigbeeChannel): self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - setattr(self, f"_{attr_name}", value) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", AttributeUpdateRecord(attrid, attr_name, value), ) - async def _chunk_attr_read(self, attrs, cached=False): - chunk, attrs = attrs[:4], attrs[4:] - while chunk: - res, fail = await self.cluster.read_attributes(chunk, allow_cache=cached) - self.debug("read attributes: Success: %s. Failed: %s", res, fail) - for attr in chunk: - self._init_attrs.pop(attr, None) - if attr in fail: - continue - if isinstance(attr, str): - setattr(self, f"_{attr}", res[attr]) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - AttributeUpdateRecord(None, attr, res[attr]), - ) - - chunk, attrs = attrs[:4], attrs[4:] - - async def configure_reporting(self): - """Configure attribute reporting for a cluster. - - This also swallows DeliveryError exceptions that are thrown when - devices are unreachable. - """ - kwargs = {} - if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: - kwargs["manufacturer"] = self._ch_pool.manufacturer_code - - chunk, rest = self._report_config[:4], self._report_config[4:] - while chunk: - attrs = {record["attr"]: record["config"] for record in chunk} - try: - res = await self.cluster.configure_reporting_multiple(attrs, **kwargs) - self._configure_reporting_status(attrs, res[0]) - except (ZigbeeException, asyncio.TimeoutError) as ex: - self.debug( - "failed to set reporting on '%s' cluster for: %s", - self.cluster.ep_attribute, - str(ex), - ) - break - chunk, rest = rest[:4], rest[4:] - - def _configure_reporting_status( - self, attrs: dict[int | str, tuple], res: list | tuple - ) -> None: - """Parse configure reporting result.""" - if not isinstance(res, list): - # assume default response - self.debug( - "attr reporting for '%s' on '%s': %s", - attrs, - self.name, - res, - ) - return - if res[0].status == Status.SUCCESS and len(res) == 1: - self.debug( - "Successfully configured reporting for '%s' on '%s' cluster: %s", - attrs, - self.name, - res, - ) - return - - failed = [ - self.cluster.attributes.get(r.attrid, [r.attrid])[0] - for r in res - if r.status != Status.SUCCESS - ] - attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} - self.debug( - "Successfully configured reporting for '%s' on '%s' cluster", - attrs - set(failed), - self.name, - ) - self.debug( - "Failed to configure reporting for '%s' on '%s' cluster: %s", - failed, - self.name, - res, - ) - - @retryable_req(delays=(1, 1, 3)) - async def async_initialize_channel_specific(self, from_cache: bool) -> None: - """Initialize channel.""" - - cached = [a for a, cached in self._init_attrs.items() if cached] - uncached = [a for a, cached in self._init_attrs.items() if not cached] - - await self._chunk_attr_read(cached, cached=True) - await self._chunk_attr_read(uncached, cached=False) - async def async_set_operation_mode(self, mode) -> bool: """Set Operation mode.""" if not await self.write_attributes({"system_mode": mode}): self.debug("couldn't set '%s' operation mode", mode) return False - self._system_mode = mode self.debug("set system to %s", mode) return True @@ -382,11 +259,6 @@ class ThermostatChannel(ZigbeeChannel): self.debug("couldn't set heating setpoint") return False - if is_away: - self._unoccupied_heating_setpoint = temperature - else: - self._occupied_heating_setpoint = temperature - self.debug("set heating setpoint to %s", temperature) return True async def async_set_cooling_setpoint( @@ -400,10 +272,6 @@ class ThermostatChannel(ZigbeeChannel): if not await self.write_attributes(data): self.debug("couldn't set cooling setpoint") return False - if is_away: - self._unoccupied_cooling_setpoint = temperature - else: - self._occupied_cooling_setpoint = temperature self.debug("set cooling setpoint to %s", temperature) return True @@ -414,7 +282,6 @@ class ThermostatChannel(ZigbeeChannel): self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) if "occupancy" not in res: return None - self._occupancy = res["occupancy"] return bool(self.occupancy) except ZigbeeException as ex: self.debug("Couldn't read 'occupancy' attribute: %s", ex) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index fbf53bec9a5..1dbf1d201c8 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -1,7 +1,6 @@ """Lighting channels module for Zigbee Home Automation.""" from __future__ import annotations -from collections.abc import Coroutine from contextlib import suppress from zigpy.zcl.clusters import lighting @@ -36,6 +35,12 @@ class ColorChannel(ZigbeeChannel): ) MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 + ZCL_INIT_ATTRS = { + "color_temp_physical_min": True, + "color_temp_physical_max": True, + "color_capabilities": True, + "color_loop_active": False, + } @property def color_capabilities(self) -> int: @@ -75,22 +80,3 @@ class ColorChannel(ZigbeeChannel): def max_mireds(self) -> int: """Return the warmest color_temp that this channel supports.""" return self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) - - def async_configure_channel_specific(self) -> Coroutine: - """Configure channel.""" - return self.fetch_color_capabilities(False) - - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - return self.fetch_color_capabilities(True) - - async def fetch_color_capabilities(self, from_cache: bool) -> None: - """Get the color configuration.""" - attributes = [ - "color_temp_physical_min", - "color_temp_physical_max", - "color_capabilities", - "color_temperature", - ] - # just populates the cache, if not already done - await self.get_attributes(attributes, from_cache=from_cache) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index cb90c740065..0800fee1374 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -7,7 +7,6 @@ https://home-assistant.io/integrations/zha/ from __future__ import annotations import asyncio -from collections.abc import Coroutine import logging from zigpy.exceptions import ZigbeeException @@ -345,6 +344,8 @@ class IasWd(ZigbeeChannel): class IASZoneChannel(ZigbeeChannel): """Channel for the IASZone Zigbee cluster.""" + ZCL_INIT_ATTRS = {"zone_status": True, "zone_state": False, "zone_type": True} + @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" @@ -404,8 +405,3 @@ class IASZoneChannel(ZigbeeChannel): self.cluster.attributes.get(attrid, [attrid])[0], value, ) - - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - attributes = ["zone_status", "zone_state", "zone_type"] - return self.get_attributes(attributes, from_cache=from_cache) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 4e6302d32b5..f3f0e76a5fb 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -1,21 +1,13 @@ """Smart energy channels module for Zigbee Home Automation.""" from __future__ import annotations -from collections.abc import Coroutine +import enum +from functools import partialmethod from zigpy.zcl.clusters import smartenergy -from homeassistant.const import ( - POWER_WATT, - TIME_HOURS, - TIME_SECONDS, - VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, - VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, -) -from homeassistant.core import callback - from .. import registries, typing as zha_typing -from ..const import REPORT_CONFIG_DEFAULT +from ..const import REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP from .base import ZigbeeChannel @@ -63,95 +55,151 @@ class Messaging(ZigbeeChannel): class Metering(ZigbeeChannel): """Metering channel.""" - REPORT_CONFIG = [{"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT}] - - unit_of_measure_map = { - 0x00: POWER_WATT, - 0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - 0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, - 0x03: f"ccf/{TIME_HOURS}", - 0x04: f"US gal/{TIME_HOURS}", - 0x05: f"IMP gal/{TIME_HOURS}", - 0x06: f"BTU/{TIME_HOURS}", - 0x07: f"l/{TIME_HOURS}", - 0x08: "kPa", - 0x09: "kPa", - 0x0A: f"mcf/{TIME_HOURS}", - 0x0B: "unitless", - 0x0C: f"MJ/{TIME_SECONDS}", + REPORT_CONFIG = ( + {"attr": "instantaneous_demand", "config": REPORT_CONFIG_OP}, + {"attr": "current_summ_delivered", "config": REPORT_CONFIG_DEFAULT}, + {"attr": "status", "config": REPORT_CONFIG_ASAP}, + ) + ZCL_INIT_ATTRS = { + "demand_formatting": True, + "divisor": True, + "metering_device_type": True, + "multiplier": True, + "summa_formatting": True, + "unit_of_measure": True, } + metering_device_type = { + 0: "Electric Metering", + 1: "Gas Metering", + 2: "Water Metering", + 3: "Thermal Metering", + 4: "Pressure Metering", + 5: "Heat Metering", + 6: "Cooling Metering", + 128: "Mirrored Gas Metering", + 129: "Mirrored Water Metering", + 130: "Mirrored Thermal Metering", + 131: "Mirrored Pressure Metering", + 132: "Mirrored Heat Metering", + 133: "Mirrored Cooling Metering", + } + + class DeviceStatusElectric(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + POWER_FAILURE = 8 + POWER_QUALITY = 16 + LEAK_DETECT = 32 # Really? + SERVICE_DISCONNECT = 64 + RESERVED = 128 + + class DeviceStatusDefault(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + + class FormatSelector(enum.IntEnum): + """Format specified selector.""" + + DEMAND = 0 + SUMMATION = 1 + def __init__( self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) self._format_spec = None + self._summa_format = None @property def divisor(self) -> int: """Return divisor for the value.""" return self.cluster.get("divisor") or 1 + @property + def device_type(self) -> int | None: + """Return metering device type.""" + dev_type = self.cluster.get("metering_device_type") + if dev_type is None: + return None + return self.metering_device_type.get(dev_type, dev_type) + @property def multiplier(self) -> int: """Return multiplier for the value.""" return self.cluster.get("multiplier") or 1 - def async_configure_channel_specific(self) -> Coroutine: - """Configure channel.""" - return self.fetch_config(False) - - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel.""" - return self.fetch_config(True) - - @callback - def attribute_updated(self, attrid: int, value: int) -> None: - """Handle attribute update from Metering cluster.""" - if None in (self.multiplier, self.divisor, self._format_spec): - return - super().attribute_updated(attrid, value) + @property + def status(self) -> int | None: + """Return metering device status.""" + status = self.cluster.get("status") + if status is None: + return None + if self.cluster.get("metering_device_type") == 0: + # Electric metering device type + return self.DeviceStatusElectric(status) + return self.DeviceStatusDefault(status) @property def unit_of_measurement(self) -> str: """Return unit of measurement.""" - uom = self.cluster.get("unit_of_measure", 0x7F) - return self.unit_of_measure_map.get(uom & 0x7F, "unknown") + return self.cluster.get("unit_of_measure") - async def fetch_config(self, from_cache: bool) -> None: + async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" - results = await self.get_attributes( - ["divisor", "multiplier", "unit_of_measure", "demand_formatting"], - from_cache=from_cache, - ) - fmting = results.get( + fmting = self.cluster.get( "demand_formatting", 0xF9 ) # 1 digit to the right, 15 digits to the left + self._format_spec = self.get_formatting(fmting) - r_digits = int(fmting & 0x07) # digits to the right of decimal point - l_digits = (fmting >> 3) & 0x0F # digits to the left of decimal point + fmting = self.cluster.get( + "summa_formatting", 0xF9 + ) # 1 digit to the right, 15 digits to the left + self._summa_format = self.get_formatting(fmting) + + @staticmethod + def get_formatting(formatting: int) -> str: + """Return a formatting string, given the formatting value. + + Bits 0 to 2: Number of Digits to the right of the Decimal Point. + Bits 3 to 6: Number of Digits to the left of the Decimal Point. + Bit 7: If set, suppress leading zeros. + """ + r_digits = int(formatting & 0x07) # digits to the right of decimal point + l_digits = (formatting >> 3) & 0x0F # digits to the left of decimal point if l_digits == 0: l_digits = 15 width = r_digits + l_digits + (1 if r_digits > 0 else 0) - if fmting & 0x80: - self._format_spec = "{:" + str(width) + "." + str(r_digits) + "f}" - else: - self._format_spec = "{:0" + str(width) + "." + str(r_digits) + "f}" + if formatting & 0x80: + # suppress leading 0 + return f"{{:{width}.{r_digits}f}}" - def formatter_function(self, value: int) -> int | float: + return f"{{:0{width}.{r_digits}f}}" + + def _formatter_function(self, selector: FormatSelector, value: int) -> int | float: """Return formatted value for display.""" value = value * self.multiplier / self.divisor - if self.unit_of_measurement == POWER_WATT: + if self.unit_of_measurement == 0: # Zigbee spec power unit is kW, but we show the value in W value_watt = value * 1000 if value_watt < 100: return round(value_watt, 1) return round(value_watt) + if selector == self.FormatSelector.SUMMATION: + return self._summa_format.format(value).lstrip() return self._format_spec.format(value).lstrip() + demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) + summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) + @registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Prepayment.cluster_id) class Prepayment(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ecb65981637..dd6832e0d6b 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,7 +7,6 @@ import logging import bellows.zigbee.application import voluptuous as vol from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import -import zigpy_cc.zigbee.application import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application import zigpy_zigate.zigbee.application @@ -73,6 +72,7 @@ BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 2560 BINDINGS = "bindings" CHANNEL_ACCELEROMETER = "accelerometer" +CHANNEL_BINARY_INPUT = "binary_input" CHANNEL_ANALOG_INPUT = "analog_input" CHANNEL_ANALOG_OUTPUT = "analog_output" CHANNEL_ATTRIBUTE = "attribute" @@ -180,7 +180,6 @@ DATA_ZHA_SHUTDOWN_TASK = "zha_shutdown_task" DEBUG_COMP_BELLOWS = "bellows" DEBUG_COMP_ZHA = "homeassistant.components.zha" DEBUG_COMP_ZIGPY = "zigpy" -DEBUG_COMP_ZIGPY_CC = "zigpy_cc" DEBUG_COMP_ZIGPY_ZNP = "zigpy_znp" DEBUG_COMP_ZIGPY_DECONZ = "zigpy_deconz" DEBUG_COMP_ZIGPY_XBEE = "zigpy_xbee" @@ -191,7 +190,6 @@ DEBUG_LEVELS = { DEBUG_COMP_BELLOWS: logging.DEBUG, DEBUG_COMP_ZHA: logging.DEBUG, DEBUG_COMP_ZIGPY: logging.DEBUG, - DEBUG_COMP_ZIGPY_CC: logging.DEBUG, DEBUG_COMP_ZIGPY_ZNP: logging.DEBUG, DEBUG_COMP_ZIGPY_DECONZ: logging.DEBUG, DEBUG_COMP_ZIGPY_XBEE: logging.DEBUG, @@ -245,10 +243,6 @@ class RadioType(enum.Enum): "deCONZ = dresden elektronik deCONZ protocol: ConBee I/II, RaspBee I/II", zigpy_deconz.zigbee.application.ControllerApplication, ) - ti_cc = ( - "Legacy TI_CC = Texas Instruments Z-Stack ZNP protocol: CC253x, CC26x2, CC13x2", - zigpy_cc.zigbee.application.ControllerApplication, - ) zigate = ( "ZiGate = ZiGate Zigbee radios: PiZiGate, ZiGate USB-TTL, ZiGate WiFi", zigpy_zigate.zigbee.application.ControllerApplication, @@ -287,6 +281,7 @@ class RadioType(enum.Enum): return self._desc +REPORT_CONFIG_ATTR_PER_REQ = 3 REPORT_CONFIG_MAX_INT = 900 REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 REPORT_CONFIG_MIN_INT = 30 @@ -379,6 +374,7 @@ ZHA_CHANNEL_MSG_BIND = "zha_channel_bind" ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" +ZHA_CHANNEL_READS_PER_REQ = 5 ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" ZHA_GW_MSG_DEVICE_INFO = "device_info" diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index c3eec07e980..a27e4cc0bfc 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -1,7 +1,8 @@ """Decorators for ZHA core registries.""" from __future__ import annotations -from typing import Callable, TypeVar +from collections.abc import Callable +from typing import TypeVar CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 9e8a8450ec1..82e2b85173e 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 " - f"of 0x{endpoint.profile_id:04x} profile id" + f"of 0x{(endpoint.profile_id or 0xFFFF):04x} profile id" } ) device_info[ATTR_ENDPOINT_NAMES] = names diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 6545f14668f..4d70c7aea96 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -2,8 +2,8 @@ from __future__ import annotations from collections import Counter +from collections.abc import Callable import logging -from typing import Callable from homeassistant import const as ha_const from homeassistant.core import HomeAssistant, callback @@ -46,7 +46,8 @@ async def async_add_entities( """Add entities helper.""" if not entities: return - to_add = [ent_cls(*args) for ent_cls, args in entities] + to_add = [ent_cls.create_entity(*args) for ent_cls, args in entities] + to_add = [entity for entity in to_add if entity is not None] _async_add_entities(to_add, update_before_add=update_before_add) entities.clear() @@ -63,6 +64,7 @@ class ProbeEndpoint: """Process an endpoint on a zigpy device.""" self.discover_by_device_type(channel_pool) self.discover_by_cluster_id(channel_pool) + self.discover_multi_entities(channel_pool) @callback def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: @@ -159,6 +161,31 @@ class ProbeEndpoint: channel = channel_class(cluster, ep_channels) self.probe_single_cluster(component, channel, ep_channels) + @staticmethod + @callback + def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on and discover multiple entities.""" + + remaining_channels = channel_pool.unclaimed_channels() + for channel in remaining_channels: + unique_id = f"{channel_pool.unique_id}-{channel.cluster.cluster_id}" + + matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( + channel_pool.manufacturer, + channel_pool.model, + channel, + remaining_channels, + ) + if not claimed: + continue + + channel_pool.claim_channels(claimed) + for component, ent_classes_list in matches.items(): + for entity_class in ent_classes_list: + channel_pool.async_new_entity( + component, entity_class, unique_id, claimed + ) + def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {}) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 50da16802b3..4e793b39a8a 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,10 +46,10 @@ from .const import ( DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, - DEBUG_COMP_ZIGPY_CC, DEBUG_COMP_ZIGPY_DECONZ, DEBUG_COMP_ZIGPY_XBEE, DEBUG_COMP_ZIGPY_ZIGATE, + DEBUG_COMP_ZIGPY_ZNP, DEBUG_LEVEL_CURRENT, DEBUG_LEVEL_ORIGINAL, DEBUG_LEVELS, @@ -689,7 +689,9 @@ def async_capture_log_levels(): DEBUG_COMP_BELLOWS: logging.getLogger(DEBUG_COMP_BELLOWS).getEffectiveLevel(), DEBUG_COMP_ZHA: logging.getLogger(DEBUG_COMP_ZHA).getEffectiveLevel(), DEBUG_COMP_ZIGPY: logging.getLogger(DEBUG_COMP_ZIGPY).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_CC: logging.getLogger(DEBUG_COMP_ZIGPY_CC).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_ZNP: logging.getLogger( + DEBUG_COMP_ZIGPY_ZNP + ).getEffectiveLevel(), DEBUG_COMP_ZIGPY_DECONZ: logging.getLogger( DEBUG_COMP_ZIGPY_DECONZ ).getEffectiveLevel(), @@ -708,7 +710,7 @@ def async_set_logger_levels(levels): logging.getLogger(DEBUG_COMP_BELLOWS).setLevel(levels[DEBUG_COMP_BELLOWS]) logging.getLogger(DEBUG_COMP_ZHA).setLevel(levels[DEBUG_COMP_ZHA]) logging.getLogger(DEBUG_COMP_ZIGPY).setLevel(levels[DEBUG_COMP_ZIGPY]) - logging.getLogger(DEBUG_COMP_ZIGPY_CC).setLevel(levels[DEBUG_COMP_ZIGPY_CC]) + logging.getLogger(DEBUG_COMP_ZIGPY_ZNP).setLevel(levels[DEBUG_COMP_ZIGPY_ZNP]) logging.getLogger(DEBUG_COMP_ZIGPY_DECONZ).setLevel(levels[DEBUG_COMP_ZIGPY_DECONZ]) logging.getLogger(DEBUG_COMP_ZIGPY_XBEE).setLevel(levels[DEBUG_COMP_ZIGPY_XBEE]) logging.getLogger(DEBUG_COMP_ZIGPY_ZIGATE).setLevel(levels[DEBUG_COMP_ZIGPY_ZIGATE]) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 34359c19420..47ee682b46e 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -8,14 +8,14 @@ from __future__ import annotations import asyncio import binascii -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import dataclass import functools import itertools import logging from random import uniform import re -from typing import Any, Callable +from typing import Any import voluptuous as vol import zigpy.exceptions diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 04e97f8b7ed..203867db17d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -2,7 +2,8 @@ from __future__ import annotations import collections -from typing import Callable, Dict +from collections.abc import Callable +from typing import Dict import attr from zigpy import zcl @@ -66,6 +67,7 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { VOC_LEVEL_CLUSTER: SENSOR, zcl.clusters.closures.DoorLock.cluster_id: LOCK, zcl.clusters.closures.WindowCovering.cluster_id: COVER, + zcl.clusters.general.BinaryInput.cluster_id: BINARY_SENSOR, zcl.clusters.general.AnalogInput.cluster_id: SENSOR, zcl.clusters.general.AnalogOutput.cluster_id: NUMBER, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, @@ -82,7 +84,6 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR, zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, - zcl.clusters.smartenergy.Metering.cluster_id: SENSOR, } SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { @@ -245,7 +246,9 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" self._strict_registry: RegistryDictType = collections.defaultdict(dict) - self._loose_registry: RegistryDictType = collections.defaultdict(dict) + self._multi_entity_registry: RegistryDictType = collections.defaultdict( + lambda: collections.defaultdict(list) + ) self._group_registry: GroupRegistryDictType = {} def get_entity( @@ -265,6 +268,27 @@ class ZHAEntityRegistry: return default, [] + def get_multi_entity( + self, + manufacturer: str, + model: str, + primary_channel: ChannelType, + aux_channels: list[ChannelType], + components: set | None = None, + ) -> tuple[dict[str, list[CALLABLE_T]], list[ChannelType]]: + """Match ZHA Channels to potentially multiple ZHA Entity classes.""" + result: dict[str, list[CALLABLE_T]] = collections.defaultdict(list) + claimed: set[ChannelType] = set() + for component in components or self._multi_entity_registry: + matches = self._multi_entity_registry[component] + for match in sorted(matches, key=lambda x: x.weight, reverse=True): + if match.strict_matched(manufacturer, model, [primary_channel]): + claimed |= set(match.claim_channels(aux_channels)) + ent_classes = self._multi_entity_registry[component][match] + result[component].extend(ent_classes) + + return result, list(claimed) + def get_group_entity(self, component: str) -> CALLABLE_T: """Match a ZHA group to a ZHA Entity class.""" return self._group_registry.get(component) @@ -294,7 +318,7 @@ class ZHAEntityRegistry: return decorator - def loose_match( + def multipass_match( self, component: str, channel_names: Callable | set[str] | str = None, @@ -314,7 +338,7 @@ class ZHAEntityRegistry: All non empty fields of a match rule must match. """ - self._loose_registry[component][rule] = zha_entity + self._multi_entity_registry[component][rule].append(zha_entity) return zha_entity return decorator diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 15e8be0db1e..62a797d9fd5 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -1,6 +1,6 @@ """Typing helpers for ZHA component.""" - -from typing import TYPE_CHECKING, Callable, TypeVar +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar import zigpy.device import zigpy.endpoint diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 50dd7e16a28..6fd68056025 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -41,12 +41,16 @@ UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): """A base class for ZHA entities.""" + _unique_id_suffix: str | None = None + def __init__(self, unique_id: str, zha_device: ZhaDeviceType, **kwargs) -> None: """Init ZHA entity.""" self._name: str = "" self._force_update: bool = False self._should_poll: bool = False self._unique_id: str = unique_id + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device: ZhaDeviceType = zha_device @@ -142,6 +146,16 @@ class BaseZhaEntity(LogMixin, entity.Entity): class ZhaEntity(BaseZhaEntity, RestoreEntity): """A base class for non group ZHA entities.""" + def __init_subclass__(cls, id_suffix: str | None = None, **kwargs) -> None: + """Initialize subclass. + + :param id_suffix: suffix to add to the unique_id of the entity. Used for multi + entities using the same channel/cluster id for the entity. + """ + super().__init_subclass__(**kwargs) + if id_suffix: + cls._unique_id_suffix = id_suffix + def __init__( self, unique_id: str, @@ -155,10 +169,26 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): ch_names = [ch.cluster.ep_attribute for ch in channels] ch_names = ", ".join(sorted(ch_names)) self._name: str = f"{zha_device.name} {ieeetail} {ch_names}" + if self._unique_id_suffix: + self._name += f" {self._unique_id_suffix}" self.cluster_channels: dict[str, ChannelType] = {} for channel in channels: self.cluster_channels[channel.name] = channel + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(unique_id, zha_device, channels, **kwargs) + @property def available(self) -> bool: """Return entity availability.""" @@ -238,6 +268,16 @@ class ZhaGroupEntity(BaseZhaEntity): """Return entity availability.""" return self._available + @classmethod + def create_entity( + cls, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs + ) -> ZhaGroupEntity | None: + """Group Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(entity_ids, unique_id, group_id, zha_device, **kwargs) + async def _handle_group_membership_changed(self): """Handle group membership changed.""" # Make sure we don't call remove twice as members are removed diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4b2b27e829c..1a9fa64c453 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,19 +4,20 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.27.0", + "bellows==0.28.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.60", - "zigpy-cc==0.5.2", + "zha-quirks==0.0.62", "zigpy-deconz==0.13.0", - "zigpy==0.37.1", + "zigpy==0.38.0", "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", "zigpy-znp==0.5.4" ], "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"10C4","pid":"EA60","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, + {"vid":"1A86","pid":"7523","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index cc401cb1e05..342fbd58d89 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -16,17 +16,28 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, + TIME_HOURS, + TIME_SECONDS, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, + VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + VOLUME_GALLONS, + VOLUME_LITERS, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -72,6 +83,7 @@ BATTERY_SIZES = { CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN) async def async_setup_entry( @@ -262,21 +274,100 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000)), 1) -@STRICT_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) class SmartEnergyMetering(Sensor): """Metering sensor.""" - SENSOR_ATTR = "instantaneous_demand" - _device_class = DEVICE_CLASS_POWER + SENSOR_ATTR: int | str = "instantaneous_demand" + _device_class: str | None = DEVICE_CLASS_POWER + _state_class: str | None = STATE_CLASS_MEASUREMENT + + unit_of_measure_map = { + 0x00: POWER_WATT, + 0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + 0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + 0x03: f"100 {VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR}", + 0x04: f"US {VOLUME_GALLONS}/{TIME_HOURS}", + 0x05: f"IMP {VOLUME_GALLONS}/{TIME_HOURS}", + 0x06: f"BTU/{TIME_HOURS}", + 0x07: f"l/{TIME_HOURS}", + 0x08: "kPa", # gauge + 0x09: "kPa", # absolute + 0x0A: f"1000 {VOLUME_GALLONS}/{TIME_HOURS}", + 0x0B: "unitless", + 0x0C: f"MJ/{TIME_SECONDS}", + } + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + se_channel = channels[0] + if cls.SENSOR_ATTR in se_channel.cluster.unsupported_attributes: + return None + + return cls(unique_id, zha_device, channels, **kwargs) def formatter(self, value: int) -> int | float: """Pass through channel formatter.""" - return self._channel.formatter_function(value) + return self._channel.demand_formatter(value) @property def native_unit_of_measurement(self) -> str: """Return Unit of measurement.""" - return self._channel.unit_of_measurement + return self.unit_of_measure_map.get(self._channel.unit_of_measurement) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attrs for battery sensors.""" + attrs = {} + if self._channel.device_type is not None: + attrs["device_type"] = self._channel.device_type + status = self._channel.status + if status is not None: + attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] + return attrs + + +@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): + """Smart Energy Metering summation sensor.""" + + SENSOR_ATTR: int | str = "current_summ_delivered" + _device_class: str | None = DEVICE_CLASS_ENERGY + _state_class: str = STATE_CLASS_TOTAL_INCREASING + + unit_of_measure_map = { + 0x00: ENERGY_KILO_WATT_HOUR, + 0x01: VOLUME_CUBIC_METERS, + 0x02: VOLUME_CUBIC_FEET, + 0x03: f"100 {VOLUME_CUBIC_FEET}", + 0x04: f"US {VOLUME_GALLONS}", + 0x05: f"IMP {VOLUME_GALLONS}", + 0x06: "BTU", + 0x07: VOLUME_LITERS, + 0x08: "kPa", # gauge + 0x09: "kPa", # absolute + 0x0A: f"1000 {VOLUME_CUBIC_FEET}", + 0x0B: "unitless", + 0x0C: "MJ", + } + + def formatter(self, value: int) -> int | float: + """Numeric pass-through formatter.""" + if self._channel.unit_of_measurement != 0: + return self._channel.summa_formatter(value) + + cooked = float(self._channel.multiplier * value) / self._channel.divisor + return round(cooked, 3) @STRICT_MATCH(channel_names=CHANNEL_PRESSURE) diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json index 0e4abfe3a57..2fe8d1a151c 100644 --- a/homeassistant/components/zha/translations/ca.json +++ b/homeassistant/components/zha/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Aquest no \u00e9s un dispositiu zha", - "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", + "usb_probe_failed": "No s'ha pogut provar el dispositiu USB" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 2c7c6fed132..88638c6c696 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Dieses Ger\u00e4t ist kein ZHA-Ger\u00e4t", - "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", + "usb_probe_failed": "Fehler beim Testen des USB-Ger\u00e4ts" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/zha/translations/el.json b/homeassistant/components/zha/translations/el.json new file mode 100644 index 00000000000..0df3306e84a --- /dev/null +++ b/homeassistant/components/zha/translations/el.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "not_zha_device": "\u0391\u03c5\u03c4\u03ae \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae zha", + "usb_probe_failed": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b1\u03bd\u03af\u03c7\u03bd\u03b5\u03c5\u03c3\u03b7\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 usb" + }, + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} ;" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index 93d3c5f697a..00c78101a53 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "This device is not a zha device", - "single_instance_allowed": "Already configured. Only a single configuration possible." + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "usb_probe_failed": "Failed to probe the usb device" }, "error": { "cannot_connect": "Failed to connect" diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json index 4753834a493..f04614f1f72 100644 --- a/homeassistant/components/zha/translations/es.json +++ b/homeassistant/components/zha/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", + "usb_probe_failed": "No se ha podido sondear el dispositivo usb" }, "error": { "cannot_connect": "No se pudo conectar" diff --git a/homeassistant/components/zha/translations/et.json b/homeassistant/components/zha/translations/et.json index 4924b3c954f..16ab4a84b6d 100644 --- a/homeassistant/components/zha/translations/et.json +++ b/homeassistant/components/zha/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "See seade ei ole zha seade", - "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine.", + "usb_probe_failed": "USB seadme k\u00fcsitlemine eba\u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus" diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 90d0908d6c3..c8b930a14d5 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "Une seule configuration de ZHA est autoris\u00e9e." + "not_zha_device": "Cet appareil n'est pas un appareil zha", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", + "usb_probe_failed": "\u00c9chec de la v\u00e9rification du p\u00e9riph\u00e9rique USB" }, "error": { - "cannot_connect": "Impossible de se connecter au p\u00e9riph\u00e9rique ZHA." + "cannot_connect": "\u00c9chec de connexion" }, "flow_title": "ZHA: {name}", "step": { + "confirm": { + "description": "Voulez-vous configurer {name} ?" + }, "pick_radio": { "data": { "radio_type": "Type de radio" diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 9722095b548..cc480bb413e 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + "not_zha_device": "Ez az eszk\u00f6z nem zha eszk\u00f6z", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", + "usb_probe_failed": "Nem siker\u00fclt megvizsg\u00e1lni az USB eszk\u00f6zt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" + }, "pick_radio": { "data": { "radio_type": "R\u00e1di\u00f3 t\u00edpusa" diff --git a/homeassistant/components/zha/translations/id.json b/homeassistant/components/zha/translations/id.json index 4198352aae8..5a10e3d01af 100644 --- a/homeassistant/components/zha/translations/id.json +++ b/homeassistant/components/zha/translations/id.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + "not_zha_device": "Perangkat ini bukan perangkat zha", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan.", + "usb_probe_failed": "Gagal mendeteksi perangkat usb" }, "error": { "cannot_connect": "Gagal terhubung" }, - "flow_title": "ZHA: {name}", + "flow_title": "{name}", "step": { + "confirm": { + "description": "Ingin menyiapkan {name}?" + }, "pick_radio": { "data": { "radio_type": "Jenis Radio" @@ -43,6 +48,7 @@ "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)", + "default_light_transition": "Waktu transisi lampu default (detik)", "enable_identify_on_join": "Aktifkan efek identifikasi saat perangkat bergabung dengan jaringan", "title": "Opsi Global" } diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json index 4cdcdd654cc..ba2427ebd4c 100644 --- a/homeassistant/components/zha/translations/it.json +++ b/homeassistant/components/zha/translations/it.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + "not_zha_device": "Questo dispositivo non \u00e8 un dispositivo zha", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", + "usb_probe_failed": "Impossibile interrogare il dispositivo USB" }, "error": { "cannot_connect": "Impossibile connettersi" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Vuoi configurare {name}?" + }, "pick_radio": { "data": { "radio_type": "Tipo di radio" diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index 9d285499ba1..54692b22598 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Dit apparaat is niet een zha-apparaat.", - "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk.", + "usb_probe_failed": "Kon het USB apparaat niet onderzoeken" }, "error": { "cannot_connect": "Kan geen verbinding maken" @@ -41,8 +42,8 @@ "zha_alarm_options": { "alarm_arm_requires_code": "Code vereist voor inschakelacties", "alarm_failed_tries": "Het aantal opeenvolgende foute codes om het alarm te activeren", - "alarm_master_code": "Mastercode voor het alarm bedieningspaneel", - "title": "Alarm bedieningspaneel Opties" + "alarm_master_code": "Mastercode voor het alarmbedieningspaneel", + "title": "Alarmbedieningspaneelopties" }, "zha_options": { "consider_unavailable_battery": "Beschouw apparaten met batterijvoeding als onbeschikbaar na (seconden)", diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json index 64986b7f6da..4e719e63ae1 100644 --- a/homeassistant/components/zha/translations/no.json +++ b/homeassistant/components/zha/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "Denne enheten er ikke en zha -enhet", - "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", + "usb_probe_failed": "Kunne ikke unders\u00f8ke usb -enheten" }, "error": { "cannot_connect": "Tilkobling mislyktes" diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json index 40a5257335f..6c67c6aea93 100644 --- a/homeassistant/components/zha/translations/pl.json +++ b/homeassistant/components/zha/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "To urz\u0105dzenie nie jest urz\u0105dzeniem zha", - "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja.", + "usb_probe_failed": "Nie uda\u0142o si\u0119 sondowa\u0107 urz\u0105dzenia USB" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index 17d95dbd7e8..f9d67cc2d35 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 ZHA.", - "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." + "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.", + "usb_probe_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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." diff --git a/homeassistant/components/zha/translations/th.json b/homeassistant/components/zha/translations/th.json new file mode 100644 index 00000000000..cd83e39dc92 --- /dev/null +++ b/homeassistant/components/zha/translations/th.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "usb_probe_failed": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c USB \u0e44\u0e21\u0e48\u0e2a\u0e33\u0e40\u0e23\u0e47\u0e08" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json index e08adf98527..28b8f70a8dd 100644 --- a/homeassistant/components/zha/translations/zh-Hant.json +++ b/homeassistant/components/zha/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "not_zha_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e ZHA \u88dd\u7f6e", - "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002", + "usb_probe_failed": "\u5075\u6e2c USB \u88dd\u7f6e\u5931\u6557" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" diff --git a/homeassistant/components/zone/translations/ru.json b/homeassistant/components/zone/translations/ru.json index 6a017e9e1c3..42de5482edb 100644 --- a/homeassistant/components/zone/translations/ru.json +++ b/homeassistant/components/zone/translations/ru.json @@ -6,7 +6,7 @@ "step": { "init": { "data": { - "icon": "\u0417\u043d\u0430\u0447\u043e\u043a", + "icon": "\u0418\u043a\u043e\u043d\u043a\u0430", "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index eb084fe1874..ef054b39714 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -37,7 +37,7 @@ async def async_attach_trigger( hass, config, action, automation_info, *, platform_type: str = "zone" ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} + trigger_data = automation_info["trigger_data"] entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) event = config.get(CONF_EVENT) diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index d392901b633..3384bad758c 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -1,10 +1,16 @@ """Support for ZoneMinder sensors.""" +from __future__ import annotations + import logging import voluptuous as vol from zoneminder.monitor import TimePeriod -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv @@ -16,13 +22,30 @@ CONF_INCLUDE_ARCHIVED = "include_archived" DEFAULT_INCLUDE_ARCHIVED = False -SENSOR_TYPES = { - "all": ["Events"], - "hour": ["Events Last Hour"], - "day": ["Events Last Day"], - "week": ["Events Last Week"], - "month": ["Events Last Month"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="all", + name="Events", + ), + SensorEntityDescription( + key="hour", + name="Events Last Hour", + ), + SensorEntityDescription( + key="day", + name="Events Last Day", + ), + SensorEntityDescription( + key="week", + name="Events Last Week", + ), + SensorEntityDescription( + key="month", + name="Events Last Month", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -30,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( CONF_INCLUDE_ARCHIVED, default=DEFAULT_INCLUDE_ARCHIVED ): cv.boolean, vol.Optional(CONF_MONITORED_CONDITIONS, default=["all"]): vol.All( - cv.ensure_list, [vol.In(list(SENSOR_TYPES))] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -38,7 +61,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder sensor platform.""" - include_archived = config.get(CONF_INCLUDE_ARCHIVED) + include_archived = config[CONF_INCLUDE_ARCHIVED] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] sensors = [] for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): @@ -49,8 +73,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for monitor in monitors: sensors.append(ZMSensorMonitors(monitor)) - for sensor in config[CONF_MONITORED_CONDITIONS]: - sensors.append(ZMSensorEvents(monitor, include_archived, sensor)) + sensors.extend( + [ + ZMSensorEvents(monitor, include_archived, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + ) sensors.append(ZMSensorRunState(zm_client)) add_entities(sensors) @@ -93,32 +122,26 @@ class ZMSensorMonitors(SensorEntity): class ZMSensorEvents(SensorEntity): """Get the number of events for each monitor.""" - def __init__(self, monitor, include_archived, sensor_type): + _attr_native_unit_of_measurement = "Events" + + def __init__(self, monitor, include_archived, description: SensorEntityDescription): """Initialize event sensor.""" + self.entity_description = description self._monitor = monitor self._include_archived = include_archived - self.time_period = TimePeriod.get_time_period(sensor_type) - self._state = None + self.time_period = TimePeriod.get_time_period(description.key) @property def name(self): """Return the name of the sensor.""" return f"{self._monitor.name} {self.time_period.title}" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "Events" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update the sensor.""" - self._state = self._monitor.get_events(self.time_period, self._include_archived) + self._attr_native_value = self._monitor.get_events( + self.time_period, self._include_archived + ) class ZMSensorRunState(SensorEntity): diff --git a/homeassistant/components/zoneminder/translations/fr.json b/homeassistant/components/zoneminder/translations/fr.json index 3f3729ce02f..7c730786384 100644 --- a/homeassistant/components/zoneminder/translations/fr.json +++ b/homeassistant/components/zoneminder/translations/fr.json @@ -23,7 +23,7 @@ "password": "Mot de passe", "path": "Chemin ZM", "path_zms": "Chemin ZMS", - "ssl": "Utiliser SSL pour les connexions \u00e0 ZoneMinder", + "ssl": "Utilise un certificat SSL", "username": "Nom d'utilisateur", "verify_ssl": "V\u00e9rifier le certificat SSL" }, diff --git a/homeassistant/components/zoneminder/translations/hu.json b/homeassistant/components/zoneminder/translations/hu.json index a449464e27f..e01f032925d 100644 --- a/homeassistant/components/zoneminder/translations/hu.json +++ b/homeassistant/components/zoneminder/translations/hu.json @@ -19,7 +19,7 @@ "step": { "user": { "data": { - "host": "Host \u00e9s Port (pl. 10.10.0.4:8010)", + "host": "C\u00edm \u00e9s Port (pl. 10.10.0.4:8010)", "password": "Jelsz\u00f3", "path": "ZM \u00fatvonal", "path_zms": "ZMS el\u00e9r\u00e9si \u00fat", diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 8a5705ae7bb..e14352a92a3 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -29,7 +29,6 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, async_get_registry as async_get_entity_registry, ) from homeassistant.helpers.entity_values import EntityValues @@ -56,11 +55,18 @@ from .const import ( DOMAIN, ) from .discovery_schemas import DISCOVERY_SCHEMAS +from .migration import ( # noqa: F401 pylint: disable=unused-import + async_add_migration_entity_value, + async_get_migration_data, + async_is_ozw_migrated, + async_is_zwave_js_migrated, +) from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from .util import ( check_has_unique_id, check_node_schema, check_value_schema, + compute_value_unique_id, is_node_parsed, node_device_id_and_name, node_name, @@ -253,64 +259,6 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_get_ozw_migration_data(hass): - """Return dict with info for migration to ozw integration.""" - data_to_migrate = {} - - zwave_config_entries = hass.config_entries.async_entries(DOMAIN) - if not zwave_config_entries: - _LOGGER.error("Config entry not set up") - return data_to_migrate - - if hass.data.get(DATA_ZWAVE_CONFIG_YAML_PRESENT): - _LOGGER.warning( - "Remove %s from configuration.yaml " - "to avoid setting up this integration on restart " - "after completing migration to ozw", - DOMAIN, - ) - - config_entry = zwave_config_entries[0] # zwave only has a single config entry - ent_reg = await async_get_entity_registry(hass) - entity_entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) - unique_entries = {entry.unique_id: entry for entry in entity_entries} - dev_reg = await async_get_device_registry(hass) - - for entity_values in hass.data[DATA_ENTITY_VALUES]: - node = entity_values.primary.node - unique_id = compute_value_unique_id(node, entity_values.primary) - if unique_id not in unique_entries: - continue - device_identifier, _ = node_device_id_and_name( - node, entity_values.primary.instance - ) - device_entry = dev_reg.async_get_device({device_identifier}, set()) - data_to_migrate[unique_id] = { - "node_id": node.node_id, - "node_instance": entity_values.primary.instance, - "device_id": device_entry.id, - "command_class": entity_values.primary.command_class, - "command_class_label": entity_values.primary.label, - "value_index": entity_values.primary.index, - "unique_id": unique_id, - "entity_entry": unique_entries[unique_id], - } - - return data_to_migrate - - -@callback -def async_is_ozw_migrated(hass): - """Return True if migration to ozw is done.""" - ozw_config_entries = hass.config_entries.async_entries("ozw") - if not ozw_config_entries: - return False - - ozw_config_entry = ozw_config_entries[0] # only one ozw entry is allowed - migrated = bool(ozw_config_entry.data.get("migrated")) - return migrated - - def _obj_to_dict(obj): """Convert an object into a hash for debug.""" return { @@ -404,9 +352,22 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 # pylint: enable=import-error from pydispatch import dispatcher - if async_is_ozw_migrated(hass): + if async_is_ozw_migrated(hass) or async_is_zwave_js_migrated(hass): + + if hass.data.get(DATA_ZWAVE_CONFIG_YAML_PRESENT): + config_yaml_message = ( + ", and remove %s from configuration.yaml " + "to avoid setting up this integration on restart ", + DOMAIN, + ) + else: + config_yaml_message = "" + _LOGGER.error( - "Migration to ozw has been done. Please remove the zwave integration" + "Migration away from legacy Z-Wave has been done. " + "Please remove the %s integration%s", + DOMAIN, + config_yaml_message, ) return False @@ -1307,6 +1268,9 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): self.refresh_from_network, ) + # Add legacy Z-Wave migration data. + await async_add_migration_entity_value(self.hass, self.entity_id, self.values) + def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" self.node_id = self.node.node_id @@ -1386,8 +1350,3 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): ) or self.node.is_ready: return compute_value_unique_id(self.node, self.values.primary) return None - - -def compute_value_unique_id(node, value): - """Compute unique_id a value would get if it were to get one.""" - return f"{node.node_id}-{value.object_id}" diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index 75780eb314a..a09f839e6c4 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -268,7 +268,7 @@ class ZWaveClimateBase(ZWaveDeviceEntity, ClimateEntity): # Default operation mode for mode in DEFAULT_HVAC_MODES: - if mode in self._hvac_mapping.keys(): + if mode in self._hvac_mapping: self._default_hvac_mode = mode break @@ -291,14 +291,14 @@ class ZWaveClimateBase(ZWaveDeviceEntity, ClimateEntity): # The current mode is not a hvac mode if ( "heat" in current_mode.lower() - and HVAC_MODE_HEAT in self._hvac_mapping.keys() + and HVAC_MODE_HEAT in self._hvac_mapping ): # The current preset modes maps to HVAC_MODE_HEAT _LOGGER.debug("Mapped to HEAT") self._hvac_mode = HVAC_MODE_HEAT elif ( "cool" in current_mode.lower() - and HVAC_MODE_COOL in self._hvac_mapping.keys() + and HVAC_MODE_COOL in self._hvac_mapping ): # The current preset modes maps to HVAC_MODE_COOL _LOGGER.debug("Mapped to COOL") diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index f65dbb557db..bf3a9abe77e 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -4,7 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave", "requirements": ["homeassistant-pyozw==0.1.10", "pydispatcher==2.0.5"], - "after_dependencies": ["ozw"], "codeowners": ["@home-assistant/z-wave"], "iot_class": "local_push" } diff --git a/homeassistant/components/zwave/migration.py b/homeassistant/components/zwave/migration.py new file mode 100644 index 00000000000..0b151d18e4b --- /dev/null +++ b/homeassistant/components/zwave/migration.py @@ -0,0 +1,167 @@ +"""Handle migration from legacy Z-Wave to OpenZWave and Z-Wave JS.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict, cast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .util import node_device_id_and_name + +if TYPE_CHECKING: + from . import ZWaveDeviceEntityValues + +LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration" +STORAGE_WRITE_DELAY = 30 +STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration" +STORAGE_VERSION = 1 + + +class ZWaveMigrationData(TypedDict): + """Represent the Z-Wave migration data dict.""" + + node_id: int + node_instance: int + command_class: int + command_class_label: str + value_index: int + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +@callback +def async_is_ozw_migrated(hass): + """Return True if migration to ozw is done.""" + ozw_config_entries = hass.config_entries.async_entries("ozw") + if not ozw_config_entries: + return False + + ozw_config_entry = ozw_config_entries[0] # only one ozw entry is allowed + migrated = bool(ozw_config_entry.data.get("migrated")) + return migrated + + +@callback +def async_is_zwave_js_migrated(hass): + """Return True if migration to Z-Wave JS is done.""" + zwave_js_config_entries = hass.config_entries.async_entries("zwave_js") + if not zwave_js_config_entries: + return False + + migrated = any( + config_entry.data.get("migrated") for config_entry in zwave_js_config_entries + ) + return migrated + + +async def async_add_migration_entity_value( + hass: HomeAssistant, + entity_id: str, + entity_values: ZWaveDeviceEntityValues, +) -> None: + """Add Z-Wave entity value for legacy Z-Wave migration.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + migration_handler.add_entity_value(entity_id, entity_values) + + +async def async_get_migration_data( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, ZWaveMigrationData]: + """Return Z-Wave migration data.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + return await migration_handler.get_data(config_entry) + + +@singleton(LEGACY_ZWAVE_MIGRATION) +async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration: + """Return legacy Z-Wave migration handler.""" + migration_handler = LegacyZWaveMigration(hass) + await migration_handler.load_data() + return migration_handler + + +class LegacyZWaveMigration: + """Handle the migration from zwave to ozw and zwave_js.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Set up migration instance.""" + self._hass = hass + self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, dict[str, ZWaveMigrationData]] = {} + + async def load_data(self) -> None: + """Load Z-Wave migration data.""" + stored = cast(dict, await self._store.async_load()) + if stored: + self._data = stored + + @callback + def save_data( + self, config_entry_id: str, entity_id: str, data: ZWaveMigrationData + ) -> None: + """Save Z-Wave migration data.""" + if config_entry_id not in self._data: + self._data[config_entry_id] = {} + self._data[config_entry_id][entity_id] = data + self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, ZWaveMigrationData]]: + """Return data to save.""" + return self._data + + @callback + def add_entity_value( + self, + entity_id: str, + entity_values: ZWaveDeviceEntityValues, + ) -> None: + """Add info for one entity and Z-Wave value.""" + ent_reg = async_get_entity_registry(self._hass) + dev_reg = async_get_device_registry(self._hass) + + node = entity_values.primary.node + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + device_identifier, _ = node_device_id_and_name( + node, entity_values.primary.instance + ) + device_entry = dev_reg.async_get_device({device_identifier}, set()) + assert device_entry + + # Normalize unit of measurement. + if unit := entity_entry.unit_of_measurement: + unit = unit.lower() + if unit == "": + unit = None + + data: ZWaveMigrationData = { + "node_id": node.node_id, + "node_instance": entity_values.primary.instance, + "command_class": entity_values.primary.command_class, + "command_class_label": entity_values.primary.label, + "value_index": entity_values.primary.index, + "device_id": device_entry.id, + "domain": entity_entry.domain, + "entity_id": entity_id, + "unique_id": entity_entry.unique_id, + "unit_of_measurement": unit, + } + + self.save_data(entity_entry.config_entry_id, entity_id, data) + + async def get_data( + self, config_entry: ConfigEntry + ) -> dict[str, ZWaveMigrationData]: + """Return Z-Wave migration data.""" + await self.load_data() + data = self._data.get(config_entry.entry_id) + return data or {} diff --git a/homeassistant/components/zwave/translations/ca.json b/homeassistant/components/zwave/translations/ca.json index 9a5ef2b5010..4b9e8953b8a 100644 --- a/homeassistant/components/zwave/translations/ca.json +++ b/homeassistant/components/zwave/translations/ca.json @@ -11,7 +11,7 @@ "user": { "data": { "network_key": "Clau de xarxa (deixa-ho en blanc per generar-la autom\u00e0ticament)", - "usb_path": "Ruta del port USB del dispositiu" + "usb_path": "Ruta del dispositiu USB" }, "description": "Aquesta integraci\u00f3 ja no s'actualitzar\u00e0. Utilitza Z-Wave JS per a instal\u00b7lacions noves.\n\nConsulta https://www.home-assistant.io/docs/z-wave/installation/ per a m\u00e9s informaci\u00f3 sobre les variables de configuraci\u00f3" } diff --git a/homeassistant/components/zwave/translations/fr.json b/homeassistant/components/zwave/translations/fr.json index 03f6f9823ad..280d86e1537 100644 --- a/homeassistant/components/zwave/translations/fr.json +++ b/homeassistant/components/zwave/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Z-Wave est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { diff --git a/homeassistant/components/zwave/translations/hu.json b/homeassistant/components/zwave/translations/hu.json index 7269ee32daf..4d0c6adff59 100644 --- a/homeassistant/components/zwave/translations/hu.json +++ b/homeassistant/components/zwave/translations/hu.json @@ -13,19 +13,19 @@ "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, - "description": "A konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3kr\u00f3l az inform\u00e1ci\u00f3kat l\u00e1sd a https://www.home-assistant.io/docs/z-wave/installation/ oldalon." + "description": "Ezt az integr\u00e1ci\u00f3t m\u00e1r nem tartj\u00e1k fenn. \u00daj telep\u00edt\u00e9sek eset\u00e9n haszn\u00e1lja helyette a Z-Wave JS-t.\n\nA konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3kkal kapcsolatos inform\u00e1ci\u00f3k\u00e9rt l\u00e1sd https://www.home-assistant.io/docs/z-wave/installation/." } } }, "state": { "_": { - "dead": "Halott", + "dead": "Nem ad \u00e9letjelet", "initializing": "Inicializ\u00e1l\u00e1s", "ready": "K\u00e9sz", "sleeping": "Alv\u00e1s" }, "query_stage": { - "dead": "Halott", + "dead": "Nem ad \u00e9letjelet", "initializing": "Inicializ\u00e1l\u00e1s" } } diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py index da8fa37f44f..19be3f7a659 100644 --- a/homeassistant/components/zwave/util.py +++ b/homeassistant/components/zwave/util.py @@ -88,6 +88,11 @@ def check_value_schema(value, schema): return True +def compute_value_unique_id(node, value): + """Compute unique_id a value would get if it were to get one.""" + return f"{node.node_id}-{value.object_id}" + + def node_name(node): """Return the name of the node.""" if is_node_parsed(node): diff --git a/homeassistant/components/zwave/websocket_api.py b/homeassistant/components/zwave/websocket_api.py index bf84a27166e..b86e46bee98 100644 --- a/homeassistant/components/zwave/websocket_api.py +++ b/homeassistant/components/zwave/websocket_api.py @@ -2,7 +2,6 @@ import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.ozw.const import DOMAIN as OZW_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import callback @@ -59,12 +58,14 @@ def websocket_get_migration_config(hass, connection, msg): @websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required(TYPE): "zwave/start_zwave_js_config_flow"} +) @websocket_api.async_response -@websocket_api.websocket_command({vol.Required(TYPE): "zwave/start_ozw_config_flow"}) -async def websocket_start_ozw_config_flow(hass, connection, msg): - """Start the ozw integration config flow (for migration wizard). +async def websocket_start_zwave_js_config_flow(hass, connection, msg): + """Start the Z-Wave JS integration config flow (for migration wizard). - Return data with the flow id of the started ozw config flow. + Return data with the flow id of the started Z-Wave JS config flow. """ config = hass.data[DATA_ZWAVE_CONFIG] data = { @@ -72,7 +73,7 @@ async def websocket_start_ozw_config_flow(hass, connection, msg): "network_key": config[CONF_NETWORK_KEY], } result = await hass.config_entries.flow.async_init( - OZW_DOMAIN, context={"source": SOURCE_IMPORT}, data=data + "zwave_js", context={"source": SOURCE_IMPORT}, data=data ) connection.send_result( msg[ID], @@ -86,4 +87,4 @@ def async_load_websocket_api(hass): websocket_api.async_register_command(hass, websocket_network_status) websocket_api.async_register_command(hass, websocket_get_config) websocket_api.async_register_command(hass, websocket_get_migration_config) - websocket_api.async_register_command(hass, websocket_start_ozw_config_flow) + websocket_api.async_register_command(hass, websocket_start_zwave_js_config_flow) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f38594c1594..c50292ea427 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict +from collections.abc import Callable from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient @@ -54,9 +55,17 @@ from .const import ( ATTR_VALUE_RAW, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_DATA_COLLECTION_OPTED_IN, CONF_INTEGRATION_CREATED_ADDON, CONF_NETWORK_KEY, + CONF_S0_LEGACY_KEY, + CONF_S2_ACCESS_CONTROL_KEY, + CONF_S2_AUTHENTICATED_KEY, + CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, DATA_CLIENT, @@ -68,7 +77,11 @@ from .const import ( ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_UPDATED_EVENT, ) -from .discovery import ZwaveDiscoveryInfo, async_discover_values +from .discovery import ( + ZwaveDiscoveryInfo, + async_discover_node_values, + async_discover_single_value, +) from .helpers import async_enable_statistics, get_device_id, get_unique_id from .migrate import async_migrate_discovered_value from .services import ZWaveServices @@ -93,11 +106,22 @@ def register_node_in_dev_reg( dev_reg: device_registry.DeviceRegistry, client: ZwaveClient, node: ZwaveNode, + remove_device_func: Callable[[device_registry.DeviceEntry], None], ) -> device_registry.DeviceEntry: """Register node in dev reg.""" + device_id = get_device_id(client, node) + # If a device already exists but it doesn't match the new node, it means the node + # was replaced with a different device and the device needs to be removeed so the + # new device can be created. Otherwise if the device exists and the node is the same, + # the node was replaced with the same device model and we can reuse the device. + if (device := dev_reg.async_get_device({device_id})) and ( + device.model != node.device_config.label + or device.manufacturer != node.device_config.manufacturer + ): + remove_device_func(device) params = { "config_entry_id": entry.entry_id, - "identifiers": {get_device_id(client, node)}, + "identifiers": {device_id}, "sw_version": node.firmware_version, "name": node.name or node.device_config.description or f"Node {node.node_id}", "model": node.device_config.label, @@ -129,15 +153,72 @@ async def async_setup_entry( # noqa: C901 entry_hass_data[DATA_PLATFORM_SETUP] = {} registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) + discovered_value_ids: dict[str, set[str]] = defaultdict(set) + + @callback + def remove_device(device: device_registry.DeviceEntry) -> None: + """Remove device from registry.""" + # note: removal of entity registry entry is handled by core + dev_reg.async_remove_device(device.id) + registered_unique_ids.pop(device.id, None) + discovered_value_ids.pop(device.id, None) + + async def async_handle_discovery_info( + device: device_registry.DeviceEntry, + disc_info: ZwaveDiscoveryInfo, + value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], + ) -> None: + """Handle discovery info and all dependent tasks.""" + # This migration logic was added in 2021.3 to handle a breaking change to + # the value_id format. Some time in the future, this call (as well as the + # helper functions) can be removed. + async_migrate_discovered_value( + hass, + ent_reg, + registered_unique_ids[device.id][disc_info.platform], + device, + client, + disc_info, + ) + + platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] + platform = disc_info.platform + if platform not in platform_setup_tasks: + platform_setup_tasks[platform] = hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + await platform_setup_tasks[platform] + + LOGGER.debug("Discovered entity: %s", disc_info) + async_dispatcher_send( + hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info + ) + + # If we don't need to watch for updates return early + if not disc_info.assumed_state: + return + value_updates_disc_info[disc_info.primary_value.value_id] = disc_info + # If this is the first time we found a value we want to watch for updates, + # return early + if len(value_updates_disc_info) != 1: + return + # add listener for value updated events + entry.async_on_unload( + disc_info.node.on( + "value updated", + lambda event: async_on_value_updated_fire_event( + value_updates_disc_info, event["value"] + ), + ) + ) async def async_on_node_ready(node: ZwaveNode) -> None: """Handle node ready event.""" LOGGER.debug("Processing node %s", node) - - platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] - # register (or update) node in device registry - device = register_node_in_dev_reg(hass, entry, dev_reg, client, node) + device = register_node_in_dev_reg( + hass, entry, dev_reg, client, node, remove_device + ) # We only want to create the defaultdict once, even on reinterviews if device.id not in registered_unique_ids: registered_unique_ids[device.id] = defaultdict(set) @@ -145,44 +226,22 @@ async def async_setup_entry( # noqa: C901 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, device): - platform = disc_info.platform - - # This migration logic was added in 2021.3 to handle a breaking change to - # the value_id format. Some time in the future, this call (as well as the - # helper functions) can be removed. - async_migrate_discovered_value( - hass, - ent_reg, - registered_unique_ids[device.id][platform], - device, - client, - disc_info, - ) - - if platform not in platform_setup_tasks: - platform_setup_tasks[platform] = hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) + await asyncio.gather( + *( + async_handle_discovery_info(device, disc_info, value_updates_disc_info) + for disc_info in async_discover_node_values( + node, device, discovered_value_ids ) - - await platform_setup_tasks[platform] - - LOGGER.debug("Discovered entity: %s", disc_info) - async_dispatcher_send( - hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info ) + ) - # Capture discovery info for values we want to watch for updates - if disc_info.assumed_state: - 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: + # add listeners to handle new values that get added later + for event in ("value added", "value updated", "metadata updated"): entry.async_on_unload( node.on( - "value updated", - lambda event: async_on_value_updated( - value_updates_disc_info, event["value"] + event, + lambda event: hass.async_create_task( + async_on_value_added(value_updates_disc_info, event["value"]) ), ) ) @@ -236,22 +295,52 @@ async def async_setup_entry( # noqa: C901 ) # we do submit the node to device registry so user has # some visual feedback that something is (in the process of) being added - register_node_in_dev_reg(hass, entry, dev_reg, client, node) + register_node_in_dev_reg(hass, entry, dev_reg, client, node, remove_device) + + async def async_on_value_added( + value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value + ) -> None: + """Fire value updated event.""" + # If node isn't ready or a device for this node doesn't already exist, we can + # let the node ready event handler perform discovery. If a value has already + # been processed, we don't need to do it again + device_id = get_device_id(client, value.node) + if ( + not value.node.ready + or not (device := dev_reg.async_get_device({device_id})) + or value.value_id in discovered_value_ids[device.id] + ): + return + + LOGGER.debug("Processing node %s added value %s", value.node, value) + await asyncio.gather( + *( + async_handle_discovery_info(device, disc_info, value_updates_disc_info) + for disc_info in async_discover_single_value( + value, device, discovered_value_ids + ) + ) + ) @callback - def async_on_node_removed(node: ZwaveNode) -> None: + def async_on_node_removed(event: dict) -> None: """Handle node removed event.""" + node: ZwaveNode = event["node"] + replaced: bool = event.get("replaced", False) # grab device in device registry attached to this node dev_id = get_device_id(client, node) device = dev_reg.async_get_device({dev_id}) - # note: removal of entity registry entry is handled by core - dev_reg.async_remove_device(device.id) # type: ignore - registered_unique_ids.pop(device.id, None) # type: ignore + # We assert because we know the device exists + assert device + if not replaced: + remove_device(device) @callback def async_on_value_notification(notification: ValueNotification) -> None: """Relay stateless value notification events from Z-Wave nodes to hass.""" device = dev_reg.async_get_device({get_device_id(client, notification.node)}) + # We assert because we know the device exists + assert device raw_value = value = notification.value if notification.metadata.states: value = notification.metadata.states.get(str(value), value) @@ -262,7 +351,7 @@ async def async_setup_entry( # noqa: C901 ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, ATTR_ENDPOINT: notification.endpoint, - ATTR_DEVICE_ID: device.id, # type: ignore + ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, ATTR_COMMAND_CLASS_NAME: notification.command_class_name, ATTR_LABEL: notification.metadata.label, @@ -281,11 +370,13 @@ async def async_setup_entry( # noqa: C901 ) -> None: """Relay stateless notification events from Z-Wave nodes to hass.""" device = dev_reg.async_get_device({get_device_id(client, notification.node)}) + # We assert because we know the device exists + assert device event_data = { ATTR_DOMAIN: DOMAIN, ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, - ATTR_DEVICE_ID: device.id, # type: ignore + ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, } @@ -313,7 +404,7 @@ async def async_setup_entry( # noqa: C901 hass.bus.async_fire(ZWAVE_JS_NOTIFICATION_EVENT, event_data) @callback - def async_on_value_updated( + def async_on_value_updated_fire_event( value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value ) -> None: """Fire value updated event.""" @@ -324,6 +415,8 @@ async def async_setup_entry( # noqa: C901 disc_info = value_updates_disc_info[value.value_id] device = dev_reg.async_get_device({get_device_id(client, value.node)}) + # We assert because we know the device exists + assert device unique_id = get_unique_id( client.driver.controller.home_id, disc_info.primary_value.value_id @@ -339,7 +432,7 @@ async def async_setup_entry( # noqa: C901 { ATTR_NODE_ID: value.node.node_id, ATTR_HOME_ID: client.driver.controller.home_id, - ATTR_DEVICE_ID: device.id, # type: ignore + ATTR_DEVICE_ID: device.id, ATTR_ENTITY_ID: entity_id, ATTR_COMMAND_CLASS: value.command_class, ATTR_COMMAND_CLASS_NAME: value.command_class_name, @@ -445,9 +538,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 entry.async_on_unload( - client.driver.controller.on( - "node removed", lambda event: async_on_node_removed(event["node"]) - ) + client.driver.controller.on("node removed", async_on_node_removed) ) platform_task = hass.async_create_task(start_platforms()) @@ -570,29 +661,61 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> raise ConfigEntryNotReady from err usb_path: str = entry.data[CONF_USB_PATH] - network_key: str = entry.data[CONF_NETWORK_KEY] + # s0_legacy_key was saved as network_key before s2 was added. + s0_legacy_key: str = entry.data.get(CONF_S0_LEGACY_KEY, "") + if not s0_legacy_key: + s0_legacy_key = entry.data.get(CONF_NETWORK_KEY, "") + s2_access_control_key: str = entry.data.get(CONF_S2_ACCESS_CONTROL_KEY, "") + s2_authenticated_key: str = entry.data.get(CONF_S2_AUTHENTICATED_KEY, "") + s2_unauthenticated_key: str = entry.data.get(CONF_S2_UNAUTHENTICATED_KEY, "") addon_state = addon_info.state if addon_state == AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( - usb_path, network_key, catch_error=True + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + catch_error=True, ) raise ConfigEntryNotReady if addon_state == AddonState.NOT_RUNNING: addon_manager.async_schedule_setup_addon( - usb_path, network_key, catch_error=True + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + catch_error=True, ) raise ConfigEntryNotReady addon_options = addon_info.options addon_device = addon_options[CONF_ADDON_DEVICE] - addon_network_key = addon_options[CONF_ADDON_NETWORK_KEY] + # s0_legacy_key was saved as network_key before s2 was added. + addon_s0_legacy_key = addon_options.get(CONF_ADDON_S0_LEGACY_KEY, "") + if not addon_s0_legacy_key: + addon_s0_legacy_key = addon_options.get(CONF_ADDON_NETWORK_KEY, "") + addon_s2_access_control_key = addon_options.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" + ) + addon_s2_authenticated_key = addon_options.get(CONF_ADDON_S2_AUTHENTICATED_KEY, "") + addon_s2_unauthenticated_key = addon_options.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" + ) updates = {} if usb_path != addon_device: updates[CONF_USB_PATH] = addon_device - if network_key != addon_network_key: - updates[CONF_NETWORK_KEY] = addon_network_key + if s0_legacy_key != addon_s0_legacy_key: + updates[CONF_S0_LEGACY_KEY] = addon_s0_legacy_key + if s2_access_control_key != addon_s2_access_control_key: + updates[CONF_S2_ACCESS_CONTROL_KEY] = addon_s2_access_control_key + if s2_authenticated_key != addon_s2_authenticated_key: + updates[CONF_S2_AUTHENTICATED_KEY] = addon_s2_authenticated_key + if s2_unauthenticated_key != addon_s2_unauthenticated_key: + updates[CONF_S2_UNAUTHENTICATED_KEY] = addon_s2_unauthenticated_key if updates: hass.config_entries.async_update_entry(entry, data={**entry.data, **updates}) diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 29ae887b4bc..38fdee9a051 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -24,7 +24,16 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton -from .const import ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, DOMAIN, LOGGER +from .const import ( + ADDON_SLUG, + CONF_ADDON_DEVICE, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + DOMAIN, + LOGGER, +) F = TypeVar("F", bound=Callable[..., Any]) # pylint: disable=invalid-name @@ -170,7 +179,13 @@ class AddonManager: @callback def async_schedule_install_setup_addon( - self, usb_path: str, network_key: str, catch_error: bool = False + self, + usb_path: str, + s0_legacy_key: str, + s2_access_control_key: str, + s2_authenticated_key: str, + s2_unauthenticated_key: str, + catch_error: bool = False, ) -> asyncio.Task: """Schedule a task that installs and sets up the Z-Wave JS add-on. @@ -180,7 +195,14 @@ class AddonManager: LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") self._install_task = self._async_schedule_addon_operation( self.async_install_addon, - partial(self.async_configure_addon, usb_path, network_key), + partial( + self.async_configure_addon, + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + ), self.async_start_addon, catch_error=catch_error, ) @@ -260,13 +282,23 @@ class AddonManager: """Stop the Z-Wave JS add-on.""" await async_stop_addon(self._hass, ADDON_SLUG) - async def async_configure_addon(self, usb_path: str, network_key: str) -> None: + async def async_configure_addon( + self, + usb_path: str, + s0_legacy_key: str, + s2_access_control_key: str, + s2_authenticated_key: str, + s2_unauthenticated_key: str, + ) -> None: """Configure and start Z-Wave JS add-on.""" addon_info = await self.async_get_addon_info() new_addon_options = { CONF_ADDON_DEVICE: usb_path, - CONF_ADDON_NETWORK_KEY: network_key, + CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, } if new_addon_options != addon_info.options: @@ -274,7 +306,13 @@ class AddonManager: @callback def async_schedule_setup_addon( - self, usb_path: str, network_key: str, catch_error: bool = False + self, + usb_path: str, + s0_legacy_key: str, + s2_access_control_key: str, + s2_authenticated_key: str, + s2_unauthenticated_key: str, + catch_error: bool = False, ) -> asyncio.Task: """Schedule a task that configures and starts the Z-Wave JS add-on. @@ -283,7 +321,14 @@ class AddonManager: if not self._start_task or self._start_task.done(): LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") self._start_task = self._async_schedule_addon_operation( - partial(self.async_configure_addon, usb_path, network_key), + partial( + self.async_configure_addon, + usb_path, + s0_legacy_key, + s2_access_control_key, + s2_authenticated_key, + s2_unauthenticated_key, + ), self.async_start_addon, catch_error=catch_error, ) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6cd0ea4fe44..8057b900baa 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,16 +1,22 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations +from collections.abc import Callable import dataclasses from functools import partial, wraps import json -from typing import Any, Callable +from typing import Any from aiohttp import hdrs, web, web_exceptions, web_request import voluptuous as vol from zwave_js_server import dump from zwave_js_server.client import Client -from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel +from zwave_js_server.const import ( + CommandClass, + InclusionStrategy, + LogLevel, + SecurityClass, +) from zwave_js_server.exceptions import ( BaseZwaveJSServerError, FailedCommand, @@ -19,7 +25,7 @@ from zwave_js_server.exceptions import ( SetValueFailed, ) from zwave_js_server.firmware import begin_firmware_update -from zwave_js_server.model.controller import ControllerStatistics +from zwave_js_server.model.controller import ControllerStatistics, InclusionGrant from zwave_js_server.model.firmware import ( FirmwareUpdateFinished, FirmwareUpdateProgress, @@ -47,13 +53,20 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( + BITMASK_SCHEMA, CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LOGGER, ) from .helpers import async_enable_statistics, update_data_collection_preference -from .services import BITMASK_SCHEMA +from .migrate import ( + ZWaveMigrationData, + async_get_migration_data, + async_map_legacy_zwave_values, + async_migrate_legacy_zwave, +) DATA_UNSUBSCRIBE = "unsubs" @@ -67,7 +80,8 @@ TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" VALUE = "value" -SECURE = "secure" +INCLUSION_STRATEGY = "inclusion_strategy" +PIN = "pin" # constants for log config commands CONFIG = "config" @@ -85,6 +99,13 @@ STATUS = "status" ENABLED = "enabled" OPTED_IN = "opted_in" +# constants for granting security classes +SECURITY_CLASSES = "security_classes" +CLIENT_SIDE_AUTH = "client_side_auth" + +# constants for migration +DRY_RUN = "dry_run" + def async_get_entry(orig_func: Callable) -> Callable: """Decorate async function to get entry.""" @@ -171,6 +192,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_ping_node) websocket_api.async_register_command(hass, websocket_add_node) + websocket_api.async_register_command(hass, websocket_grant_security_classes) + websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) websocket_api.async_register_command(hass, websocket_remove_node) @@ -205,6 +228,8 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) + websocket_api.async_register_command(hass, websocket_node_ready) + websocket_api.async_register_command(hass, websocket_migrate_zwave) hass.http.register_view(DumpView()) hass.http.register_view(FirmwareUploadView()) @@ -259,6 +284,42 @@ async def websocket_network_status( ) +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/node_ready", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + } +) +@websocket_api.async_response +@async_get_node +async def websocket_node_ready( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Subscribe to the node ready event of a Z-Wave JS node.""" + + @callback + def forward_event(event: dict) -> None: + """Forward the event.""" + connection.send_message( + websocket_api.event_message(msg[ID], {"event": event["event"]}) + ) + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("ready", forward_event)] + + connection.send_result(msg[ID]) + + @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/node_status", @@ -307,7 +368,7 @@ async def websocket_node_state( """Get the state data of a Z-Wave JS node.""" connection.send_result( msg[ID], - node.data, + {**node.data, "values": [value.data for value in node.values.values()]}, ) @@ -371,7 +432,9 @@ async def websocket_ping_node( { vol.Required(TYPE): "zwave_js/add_node", vol.Required(ENTRY_ID): str, - vol.Optional(SECURE, default=False): bool, + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( + [strategy.value for strategy in InclusionStrategy] + ), } ) @websocket_api.async_response @@ -386,11 +449,7 @@ async def websocket_add_node( ) -> None: """Add a node to the Z-Wave network.""" controller = client.driver.controller - - if msg[SECURE]: - inclusion_strategy = InclusionStrategy.SECURITY_S0 - else: - inclusion_strategy = InclusionStrategy.INSECURE + inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) @callback def async_cleanup() -> None: @@ -404,6 +463,26 @@ async def websocket_add_node( websocket_api.event_message(msg[ID], {"event": event["event"]}) ) + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + @callback + def forward_requested_grant(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "requested_grant": event["requested_grant"].to_dict(), + }, + ) + ) + @callback def forward_stage(event: dict) -> None: connection.send_message( @@ -426,6 +505,7 @@ async def websocket_add_node( "node_id": node.node_id, "status": node.status, "ready": node.ready, + "low_security": event["result"].get("lowSecurity", False), } connection.send_message( websocket_api.event_message( @@ -452,6 +532,8 @@ async def websocket_add_node( controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), + controller.on("validate dsk and enter pin", forward_dsk), + controller.on("grant security classes", forward_requested_grant), controller.on("node added", node_added), async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered @@ -465,6 +547,59 @@ async def websocket_add_node( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/grant_security_classes", + vol.Required(ENTRY_ID): str, + vol.Required(SECURITY_CLASSES): [ + vol.In([sec_cls.value for sec_cls in SecurityClass]) + ], + vol.Optional(CLIENT_SIDE_AUTH, default=False): bool, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_grant_security_classes( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Add a node to the Z-Wave network.""" + inclusion_grant = InclusionGrant( + [SecurityClass(sec_cls) for sec_cls in msg[SECURITY_CLASSES]], + msg[CLIENT_SIDE_AUTH], + ) + await client.driver.controller.async_grant_security_classes(inclusion_grant) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/validate_dsk_and_enter_pin", + vol.Required(ENTRY_ID): str, + vol.Required(PIN): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_validate_dsk_and_enter_pin( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Add a node to the Z-Wave network.""" + await client.driver.controller.async_validate_dsk_and_enter_pin(msg[PIN]) + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -583,7 +718,9 @@ async def websocket_remove_node( vol.Required(TYPE): "zwave_js/replace_failed_node", vol.Required(ENTRY_ID): str, vol.Required(NODE_ID): int, - vol.Optional(SECURE, default=False): bool, + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( + [strategy.value for strategy in InclusionStrategy] + ), } ) @websocket_api.async_response @@ -599,11 +736,7 @@ async def websocket_replace_failed_node( """Replace a failed node with a new node.""" controller = client.driver.controller node_id = msg[NODE_ID] - - if msg[SECURE]: - inclusion_strategy = InclusionStrategy.SECURITY_S0 - else: - inclusion_strategy = InclusionStrategy.INSECURE + inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) @callback def async_cleanup() -> None: @@ -617,6 +750,26 @@ async def websocket_replace_failed_node( websocket_api.event_message(msg[ID], {"event": event["event"]}) ) + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + @callback + def forward_requested_grant(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "requested_grant": event["requested_grant"].to_dict(), + }, + ) + ) + @callback def forward_stage(event: dict) -> None: connection.send_message( @@ -678,6 +831,8 @@ async def websocket_replace_failed_node( controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), + controller.on("validate dsk and enter pin", forward_dsk), + controller.on("grant security classes", forward_requested_grant), controller.on("node removed", node_removed), controller.on("node added", node_added), async_dispatcher_connect( @@ -1274,6 +1429,7 @@ class DumpView(HomeAssistantView): async def get(self, request: web.Request, config_entry_id: str) -> web.Response: """Dump the state of Z-Wave.""" + # pylint: disable=no-self-use if not request["hass_user"].is_admin: raise Unauthorized() hass = request.app["hass"] @@ -1635,3 +1791,72 @@ async def websocket_subscribe_node_statistics( connection.subscriptions[msg["id"]] = async_cleanup connection.send_result(msg[ID], _get_node_statistics_dict(node.statistics)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/migrate_zwave", + vol.Required(ENTRY_ID): str, + vol.Optional(DRY_RUN, default=True): bool, + } +) +@websocket_api.async_response +@async_get_entry +async def websocket_migrate_zwave( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Migrate Z-Wave device and entity data to Z-Wave JS integration.""" + if "zwave" not in hass.config.components: + connection.send_message( + websocket_api.error_message( + msg["id"], "zwave_not_loaded", "Integration zwave is not loaded" + ) + ) + return + + zwave = hass.components.zwave + zwave_config_entries = hass.config_entries.async_entries("zwave") + zwave_config_entry = zwave_config_entries[0] # zwave only has a single config entry + zwave_data: dict[str, ZWaveMigrationData] = await zwave.async_get_migration_data( + hass, zwave_config_entry + ) + LOGGER.debug("Migration zwave data: %s", zwave_data) + + zwave_js_config_entry = entry + zwave_js_data = await async_get_migration_data(hass, zwave_js_config_entry) + LOGGER.debug("Migration zwave_js data: %s", zwave_js_data) + + migration_map = async_map_legacy_zwave_values(zwave_data, zwave_js_data) + + zwave_entity_ids = [entry["entity_id"] for entry in zwave_data.values()] + zwave_js_entity_ids = [entry["entity_id"] for entry in zwave_js_data.values()] + migration_device_map = { + zwave_device_id: zwave_js_device_id + for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items() + } + migration_entity_map = { + zwave_entry["entity_id"]: zwave_js_entity_id + for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items() + } + LOGGER.debug("Migration entity map: %s", migration_entity_map) + + if not msg[DRY_RUN]: + await async_migrate_legacy_zwave( + hass, zwave_config_entry, zwave_js_config_entry, migration_map + ) + + connection.send_result( + msg[ID], + { + "migration_device_map": migration_device_map, + "zwave_entity_ids": zwave_entity_ids, + "zwave_js_entity_ids": zwave_js_entity_ids, + "migration_entity_map": migration_entity_map, + "migrated": not msg[DRY_RUN], + }, + ) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 9d72a804ca0..4007064109d 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -6,6 +6,10 @@ from typing import TypedDict from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY +from zwave_js_server.const.command_class.notification import ( + CC_SPECIFIC_NOTIFICATION_TYPE, +) from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, @@ -196,9 +200,6 @@ NOTIFICATION_SENSOR_MAPPINGS: list[NotificationSensorMapping] = [ ] -PROPERTY_DOOR_STATUS = "doorStatus" - - class PropertySensorMapping(TypedDict, total=False): """Represent a property sensor mapping dict type.""" @@ -211,7 +212,7 @@ class PropertySensorMapping(TypedDict, total=False): # Mappings for property sensors PROPERTY_SENSOR_MAPPINGS: list[PropertySensorMapping] = [ { - "property_name": PROPERTY_DOOR_STATUS, + "property_name": DOOR_STATUS_PROPERTY, "on_states": ["open"], "device_class": DEVICE_CLASS_DOOR, "enabled": True, @@ -327,7 +328,9 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): for mapping in NOTIFICATION_SENSOR_MAPPINGS: if ( mapping["type"] - != self.info.primary_value.metadata.cc_specific["notificationType"] + != self.info.primary_value.metadata.cc_specific[ + CC_SPECIFIC_NOTIFICATION_TYPE + ] ): continue if not mapping.get("states") or self.state_key in mapping["states"]: diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 1ec5ccbcc01..f4fd8d10886 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -7,6 +7,7 @@ from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, + THERMOSTAT_HUMIDITY_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, THERMOSTAT_MODES, @@ -176,7 +177,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): if not self._unit_value: self._unit_value = self._current_temp self._current_humidity = self.get_zwave_value( - "Humidity", + THERMOSTAT_HUMIDITY_PROPERTY, command_class=CommandClass.SENSOR_MULTILEVEL, add_to_watched_value_ids=True, check_all_endpoints=True, diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index a4f7343f0e0..37e6b7c9320 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -31,8 +31,15 @@ from .const import ( CONF_ADDON_EMULATE_HARDWARE, CONF_ADDON_LOG_LEVEL, CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_INTEGRATION_CREATED_ADDON, - CONF_NETWORK_KEY, + CONF_S0_LEGACY_KEY, + CONF_S2_ACCESS_CONTROL_KEY, + CONF_S2_AUTHENTICATED_KEY, + CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, @@ -59,7 +66,10 @@ ADDON_LOG_LEVELS = { } ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, - CONF_ADDON_NETWORK_KEY: CONF_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: CONF_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY: CONF_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: CONF_S2_UNAUTHENTICATED_KEY, CONF_ADDON_LOG_LEVEL: CONF_LOG_LEVEL, CONF_ADDON_EMULATE_HARDWARE: CONF_EMULATE_HARDWARE, } @@ -113,7 +123,10 @@ class BaseZwaveJSFlow(FlowHandler): def __init__(self) -> None: """Set up flow instance.""" - self.network_key: str | None = None + self.s0_legacy_key: str | None = None + self.s2_access_control_key: str | None = None + self.s2_authenticated_key: str | None = None + self.s2_unauthenticated_key: str | None = None self.usb_path: str | None = None self.ws_address: str | None = None self.restart_addon: bool = False @@ -302,6 +315,18 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Return the options flow.""" return OptionsFlowHandler(config_entry) + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Handle imported data. + + This step will be used when importing data + during Z-Wave to Z-Wave JS migration. + """ + # Note that the data comes from the zwave integration. + # So we don't use our constants here. + self.s0_legacy_key = data.get("network_key") + self.usb_path = data.get("usb_path") + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -450,7 +475,16 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): if addon_info.state == AddonState.RUNNING: addon_config = addon_info.options self.usb_path = addon_config[CONF_ADDON_DEVICE] - self.network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, "") + self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "") + self.s2_access_control_key = addon_config.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" + ) + self.s2_authenticated_key = addon_config.get( + CONF_ADDON_S2_AUTHENTICATED_KEY, "" + ) + self.s2_unauthenticated_key = addon_config.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" + ) return await self.async_step_finish_addon_setup() if addon_info.state == AddonState.NOT_RUNNING: @@ -466,13 +500,19 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): addon_config = addon_info.options if user_input is not None: - self.network_key = user_input[CONF_NETWORK_KEY] + self.s0_legacy_key = user_input[CONF_S0_LEGACY_KEY] + self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] + self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] + self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] new_addon_config = { **addon_config, CONF_ADDON_DEVICE: self.usb_path, - CONF_ADDON_NETWORK_KEY: self.network_key, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, } if new_addon_config != addon_config: @@ -481,12 +521,32 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" - network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") + s0_legacy_key = addon_config.get( + CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" + ) + s2_access_control_key = addon_config.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, self.s2_access_control_key or "" + ) + s2_authenticated_key = addon_config.get( + CONF_ADDON_S2_AUTHENTICATED_KEY, self.s2_authenticated_key or "" + ) + s2_unauthenticated_key = addon_config.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" + ) data_schema = vol.Schema( { vol.Required(CONF_USB_PATH, default=usb_path): str, - vol.Optional(CONF_NETWORK_KEY, default=network_key): str, + vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, + vol.Optional( + CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + ): str, + vol.Optional( + CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key + ): str, + vol.Optional( + CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + ): str, } ) @@ -521,7 +581,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): updates={ CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, - CONF_NETWORK_KEY: self.network_key, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, } ) return self._async_create_entry_from_vars() @@ -538,7 +601,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, - CONF_NETWORK_KEY: self.network_key, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_USE_ADDON: self.use_addon, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, @@ -648,13 +714,19 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): addon_config = addon_info.options if user_input is not None: - self.network_key = user_input[CONF_NETWORK_KEY] + self.s0_legacy_key = user_input[CONF_S0_LEGACY_KEY] + self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] + self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] + self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] new_addon_config = { **addon_config, CONF_ADDON_DEVICE: self.usb_path, - CONF_ADDON_NETWORK_KEY: self.network_key, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_ADDON_LOG_LEVEL: user_input[CONF_LOG_LEVEL], CONF_ADDON_EMULATE_HARDWARE: user_input[CONF_EMULATE_HARDWARE], } @@ -664,6 +736,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): self.restart_addon = True # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) + # Remove legacy network_key + new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) await self._async_set_addon_config(new_addon_config) if addon_info.state == AddonState.RUNNING and not self.restart_addon: @@ -679,14 +753,34 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): return await self.async_step_start_addon() usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") - network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") + s0_legacy_key = addon_config.get( + CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" + ) + s2_access_control_key = addon_config.get( + CONF_ADDON_S2_ACCESS_CONTROL_KEY, self.s2_access_control_key or "" + ) + s2_authenticated_key = addon_config.get( + CONF_ADDON_S2_AUTHENTICATED_KEY, self.s2_authenticated_key or "" + ) + s2_unauthenticated_key = addon_config.get( + CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" + ) log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) data_schema = vol.Schema( { vol.Required(CONF_USB_PATH, default=usb_path): str, - vol.Optional(CONF_NETWORK_KEY, default=network_key): str, + vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, + vol.Optional( + CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + ): str, + vol.Optional( + CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key + ): str, + vol.Optional( + CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + ): str, vol.Optional(CONF_LOG_LEVEL, default=log_level): vol.In( ADDON_LOG_LEVELS ), @@ -736,7 +830,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): **self.config_entry.data, CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, - CONF_NETWORK_KEY: self.network_key, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_USE_ADDON: True, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } @@ -769,6 +866,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): addon_config_input = { ADDON_USER_INPUT_MAP[addon_key]: addon_val for addon_key, addon_val in self.original_addon_config.items() + if addon_key in ADDON_USER_INPUT_MAP } _LOGGER.debug("Reverting add-on options, reason: %s", reason) return await self.async_step_configure_addon(addon_config_input) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index e4486a681e1..e484d01fccb 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,12 +1,24 @@ """Constants for the Z-Wave JS integration.""" import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + CONF_ADDON_DEVICE = "device" CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" CONF_ADDON_LOG_LEVEL = "log_level" CONF_ADDON_NETWORK_KEY = "network_key" +CONF_ADDON_S0_LEGACY_KEY = "s0_legacy_key" +CONF_ADDON_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" +CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key" +CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_NETWORK_KEY = "network_key" +CONF_S0_LEGACY_KEY = "s0_legacy_key" +CONF_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" +CONF_S2_AUTHENTICATED_KEY = "s2_authenticated_key" +CONF_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_USB_PATH = "usb_path" CONF_USE_ADDON = "use_addon" CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" @@ -56,6 +68,8 @@ ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" # service constants +SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" SERVICE_SET_VALUE = "set_value" SERVICE_RESET_METER = "reset_meter" SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" @@ -98,3 +112,25 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_TIMESTAMP = "timestamp" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" + +# Schema Constants + +# Validates that a bitmask is provided in hex form and converts it to decimal +# int equivalent since that's what the library uses +BITMASK_SCHEMA = vol.All( + cv.string, + vol.Lower, + vol.Match( + r"^(0x)?[0-9a-f]+$", + msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)", + ), + lambda value: int(value, 16), +) + +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + BITMASK_SCHEMA, + cv.string, +) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 9060e13a9a5..e9759dbb171 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,16 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY from zwave_js_server.const.command_class.barrier_operator import BarrierState +from zwave_js_server.const.command_class.multilevel_switch import ( + COVER_CLOSE_PROPERTY, + COVER_DOWN_PROPERTY, + COVER_OFF_PROPERTY, + COVER_ON_PROPERTY, + COVER_OPEN_PROPERTY, + COVER_UP_PROPERTY, +) from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( @@ -105,36 +114,36 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value( target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) ) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value(target_value, 99) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value(target_value, 0) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" open_value = ( - self.get_zwave_value("Open") - or self.get_zwave_value("Up") - or self.get_zwave_value("On") + self.get_zwave_value(COVER_OPEN_PROPERTY) + or self.get_zwave_value(COVER_UP_PROPERTY) + or self.get_zwave_value(COVER_ON_PROPERTY) ) 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") + self.get_zwave_value(COVER_CLOSE_PROPERTY) + or self.get_zwave_value(COVER_DOWN_PROPERTY) + or self.get_zwave_value(COVER_OFF_PROPERTY) ) if close_value: # Stop the cover if it's closing @@ -156,7 +165,7 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Initialize a ZwaveMotorizedBarrier entity.""" super().__init__(config_entry, client, info) self._target_state: ZwaveValue = self.get_zwave_value( - "targetState", add_to_watched_value_ids=False + TARGET_STATE_PROPERTY, add_to_watched_value_ids=False ) @property diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py new file mode 100644 index 00000000000..14d64f87eb7 --- /dev/null +++ b/homeassistant/components/zwave_js/device_action.py @@ -0,0 +1,309 @@ +"""Provides device actions for Z-Wave JS.""" +from __future__ import annotations + +from collections import defaultdict +import re +from typing import Any + +import voluptuous as vol +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.meter import CC_SPECIFIC_METER_TYPE +from zwave_js_server.model.value import get_value_id +from zwave_js_server.util.command_class.meter import get_meter_type + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER_BITMASK, + ATTR_ENDPOINT, + ATTR_METER_TYPE, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_REFRESH_ALL_VALUES, + ATTR_VALUE, + ATTR_WAIT_FOR_RESULT, + DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_PING, + SERVICE_REFRESH_VALUE, + SERVICE_RESET_METER, + SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_LOCK_USERCODE, + SERVICE_SET_VALUE, + VALUE_SCHEMA, +) +from .device_automation_helpers import ( + CONF_SUBTYPE, + VALUE_ID_REGEX, + get_config_parameter_value_schema, +) +from .helpers import async_get_node_from_device_id + +ACTION_TYPES = { + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_PING, + SERVICE_REFRESH_VALUE, + SERVICE_RESET_METER, + SERVICE_SET_CONFIG_PARAMETER, + SERVICE_SET_LOCK_USERCODE, + SERVICE_SET_VALUE, +} + +CLEAR_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_CLEAR_LOCK_USERCODE, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + } +) + +PING_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_PING, + } +) + +REFRESH_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_REFRESH_VALUE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_REFRESH_ALL_VALUES, default=False): cv.boolean, + } +) + +RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_RESET_METER, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN), + vol.Optional(ATTR_METER_TYPE): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), + } +) + +SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER, + vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str), + vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str), + vol.Required(ATTR_VALUE): vol.Coerce(int), + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_LOCK_USERCODE, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + vol.Required(ATTR_USERCODE): cv.string, + } +) + +SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SERVICE_SET_VALUE, + vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_PROPERTY): vol.Any(int, str), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(ATTR_VALUE): VALUE_SCHEMA, + vol.Optional(ATTR_WAIT_FOR_RESULT, default=False): cv.boolean, + } +) + +ACTION_SCHEMA = vol.Any( + CLEAR_LOCK_USERCODE_SCHEMA, + PING_SCHEMA, + REFRESH_VALUE_SCHEMA, + RESET_METER_SCHEMA, + SET_CONFIG_PARAMETER_SCHEMA, + SET_LOCK_USERCODE_SCHEMA, + SET_VALUE_SCHEMA, +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device actions for Z-Wave JS devices.""" + registry = entity_registry.async_get(hass) + actions = [] + + node = async_get_node_from_device_id(hass, device_id) + + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + actions.extend( + [ + {**base_action, CONF_TYPE: SERVICE_SET_VALUE}, + {**base_action, CONF_TYPE: SERVICE_PING}, + ] + ) + actions.extend( + [ + { + **base_action, + CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER, + ATTR_CONFIG_PARAMETER: config_value.property_, + ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + + meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) + + for entry in entity_registry.async_entries_for_device(registry, device_id): + entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} + actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) + if entry.domain == LOCK_DOMAIN: + actions.extend( + [ + {**entity_action, CONF_TYPE: SERVICE_SET_LOCK_USERCODE}, + {**entity_action, CONF_TYPE: SERVICE_CLEAR_LOCK_USERCODE}, + ] + ) + + if entry.domain == SENSOR_DOMAIN: + value_id = entry.unique_id.split(".")[1] + # If this unique ID doesn't have a value ID, we know it is the node status + # sensor which doesn't have any relevant actions + if re.match(VALUE_ID_REGEX, value_id): + value = node.values[value_id] + else: + continue + # If the value has the meterType CC specific value, we can add a reset_meter + # action for it + if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: + meter_endpoints[value.endpoint].setdefault( + CONF_ENTITY_ID, entry.entity_id + ) + meter_endpoints[value.endpoint].setdefault(ATTR_METER_TYPE, set()).add( + get_meter_type(value) + ) + + if not meter_endpoints: + return actions + + for endpoint, endpoint_data in meter_endpoints.items(): + base_action[CONF_ENTITY_ID] = endpoint_data[CONF_ENTITY_ID] + actions.append( + { + **base_action, + CONF_TYPE: SERVICE_RESET_METER, + CONF_SUBTYPE: f"Endpoint {endpoint} (All)", + } + ) + for meter_type in endpoint_data[ATTR_METER_TYPE]: + actions.append( + { + **base_action, + CONF_TYPE: SERVICE_RESET_METER, + ATTR_METER_TYPE: meter_type, + CONF_SUBTYPE: f"Endpoint {endpoint} ({meter_type.name})", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + action_type = service = config.pop(CONF_TYPE) + if action_type not in ACTION_TYPES: + raise HomeAssistantError(f"Unhandled action type {action_type}") + + service_data = {k: v for k, v in config.items() if v not in (None, "")} + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List action capabilities.""" + action_type = config[CONF_TYPE] + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + + # Add additional fields to the automation action UI + if action_type == SERVICE_CLEAR_LOCK_USERCODE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_CODE_SLOT): cv.string, + } + ) + } + + if action_type == SERVICE_SET_LOCK_USERCODE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_CODE_SLOT): cv.string, + vol.Required(ATTR_USERCODE): cv.string, + } + ) + } + + if action_type == SERVICE_RESET_METER: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_VALUE): cv.string, + } + ) + } + + if action_type == SERVICE_REFRESH_VALUE: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_REFRESH_ALL_VALUES): cv.boolean, + } + ) + } + + if action_type == SERVICE_SET_VALUE: + 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, + vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean, + } + ) + } + + if action_type == SERVICE_SET_CONFIG_PARAMETER: + value_id = get_value_id( + node, + CommandClass.CONFIGURATION, + config[ATTR_CONFIG_PARAMETER], + property_key=config[ATTR_CONFIG_PARAMETER_BITMASK], + ) + value_schema = get_config_parameter_value_schema(node, value_id) + if value_schema is None: + return {} + return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} + + return {} diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py new file mode 100644 index 00000000000..cfdb65a4b02 --- /dev/null +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -0,0 +1,34 @@ +"""Provides helpers for Z-Wave JS device automations.""" +from __future__ import annotations + +from typing import cast + +import voluptuous as vol +from zwave_js_server.const import ConfigurationValueType +from zwave_js_server.model.node import Node +from zwave_js_server.model.value import ConfigurationValue + +NODE_STATUSES = ["asleep", "awake", "dead", "alive"] + +CONF_SUBTYPE = "subtype" +CONF_VALUE_ID = "value_id" + +VALUE_ID_REGEX = r"([0-9]+-[0-9]+-[0-9]+-).+" + + +def get_config_parameter_value_schema(node: Node, value_id: str) -> vol.Schema | None: + """Get the extra fields schema for a config parameter value.""" + 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, + ): + return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + + if config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: + return vol.In({int(k): v for k, v in config_value.metadata.states.items()}) + + return None diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index f17654f184a..6694d88a135 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -14,7 +14,6 @@ from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CON 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 @@ -24,34 +23,37 @@ from .const import ( ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_VALUE, + VALUE_SCHEMA, +) +from .device_automation_helpers import ( + CONF_SUBTYPE, + CONF_VALUE_ID, + NODE_STATUSES, + get_config_parameter_value_schema, ) from .helpers import ( async_get_node_from_device_id, async_is_device_config_entry_not_loaded, check_type_schema_map, - get_value_state_schema, get_zwave_value_from_config, remove_keys_with_empty_values, ) -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( +NODE_STATUS_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): NODE_STATUS_TYPE, - vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES), + vol.Required(CONF_STATUS): vol.In(NODE_STATUSES), } ) -CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): CONFIG_PARAMETER_TYPE, vol.Required(CONF_VALUE_ID): cv.string, @@ -60,20 +62,14 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( } ) -VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +VALUE_CONDITION_SCHEMA = cv.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, - ), + vol.Required(ATTR_VALUE): VALUE_SCHEMA, } ) @@ -204,10 +200,9 @@ async def async_get_condition_capabilities( # Add additional fields to the automation trigger UI if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE: value_id = config[CONF_VALUE_ID] - value_schema = get_value_state_schema(node.values[value_id]) - if not value_schema: + value_schema = get_config_parameter_value_schema(node, value_id) + if value_schema is None: return {} - return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} if config[CONF_TYPE] == VALUE_TYPE: @@ -234,7 +229,7 @@ async def async_get_condition_capabilities( if config[CONF_TYPE] == NODE_STATUS_TYPE: return { "extra_fields": vol.Schema( - {vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES)} + {vol.Required(CONF_STATUS): vol.In(NODE_STATUSES)} ) } diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 7ed13ce2b98..11236697198 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -6,7 +6,10 @@ from typing import Any import voluptuous as vol from zwave_js_server.const import CommandClass -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, @@ -46,6 +49,7 @@ from .const import ( ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) +from .device_automation_helpers import CONF_SUBTYPE, NODE_STATUSES from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, @@ -62,8 +66,6 @@ from .triggers.value_updated import ( PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE, ) -CONF_SUBTYPE = "subtype" - # Trigger types ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" NOTIFICATION_NOTIFICATION = "event.notification.notification" @@ -150,8 +152,6 @@ BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -NODE_STATUSES = ["asleep", "awake", "dead", "alive"] - NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( { vol.Required(CONF_TYPE): NODE_STATUS, @@ -358,7 +358,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d5af1c072ee..23053804aae 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -6,9 +6,32 @@ from dataclasses import asdict, dataclass, field from typing import Any from awesomeversion import AwesomeVersion -from zwave_js_server.const import CommandClass +from zwave_js_server.const import ( + CURRENT_STATE_PROPERTY, + CURRENT_VALUE_PROPERTY, + TARGET_STATE_PROPERTY, + TARGET_VALUE_PROPERTY, + CommandClass, +) +from zwave_js_server.const.command_class.barrier_operator import ( + SIGNALING_STATE_PROPERTY, +) +from zwave_js_server.const.command_class.lock import ( + CURRENT_MODE_PROPERTY, + DOOR_STATUS_PROPERTY, + LOCKED_PROPERTY, +) +from zwave_js_server.const.command_class.meter import VALUE_PROPERTY +from zwave_js_server.const.command_class.protection import LOCAL_PROPERTY, RF_PROPERTY +from zwave_js_server.const.command_class.sound_switch import ( + DEFAULT_TONE_ID_PROPERTY, + DEFAULT_VOLUME_PROPERTY, + TONE_ID_PROPERTY, +) from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, + THERMOSTAT_MODE_PROPERTY, + THERMOSTAT_SETPOINT_PROPERTY, ) from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem @@ -179,16 +202,18 @@ def get_config_parameter_discovery_schema( SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_MULTILEVEL}, - property={"currentValue"}, + property={CURRENT_VALUE_PROPERTY}, type={"number"}, ) SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( - command_class={CommandClass.SWITCH_BINARY}, property={"currentValue"} + command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY} ) SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( - command_class={CommandClass.SOUND_SWITCH}, property={"toneId"}, type={"number"} + command_class={CommandClass.SOUND_SWITCH}, + property={TONE_ID_PROPERTY}, + type={"number"}, ) # For device class mapping see: @@ -229,7 +254,7 @@ DISCOVERY_SCHEMAS = [ primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_MULTILEVEL}, endpoint={2}, - property={"currentValue"}, + property={CURRENT_VALUE_PROPERTY}, type={"number"}, ), ), @@ -287,11 +312,11 @@ DISCOVERY_SCHEMAS = [ product_type={0x0003}, primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( - { + lookup_table={ # Internal Sensor "A": ZwaveValueID( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -321,7 +346,7 @@ DISCOVERY_SCHEMAS = [ endpoint=4, ), }, - ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), ), ), # Heatit Z-TRM2fx @@ -334,11 +359,11 @@ DISCOVERY_SCHEMAS = [ firmware_version_range=FirmwareVersionRange(min="3.0"), primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( - { + lookup_table={ # External Sensor "A2": ZwaveValueID( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -357,7 +382,24 @@ DISCOVERY_SCHEMAS = [ endpoint=3, ), }, - ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + ), + ), + # FortrezZ SSA1/SSA2 + ZWaveDiscoverySchema( + platform="select", + hint="multilevel_switch", + manufacturer_id={0x0084}, + product_id={0x0107, 0x0108, 0x010B, 0x0205}, + product_type={0x0311, 0x0313, 0x0341, 0x0343}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=BaseDiscoverySchemaDataTemplate( + { + 0: "Off", + 33: "Strobe ONLY", + 66: "Siren ONLY", + 99: "Siren & Strobe FULL Alarm", + }, ), ), # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= @@ -376,7 +418,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.LOCK, CommandClass.DOOR_LOCK, }, - property={"currentMode", "locked"}, + property={CURRENT_MODE_PROPERTY, LOCKED_PROPERTY}, type={"number", "boolean"}, ), ), @@ -389,7 +431,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.LOCK, CommandClass.DOOR_LOCK, }, - property={"doorStatus"}, + property={DOOR_STATUS_PROPERTY}, type={"any"}, ), ), @@ -399,7 +441,7 @@ DISCOVERY_SCHEMAS = [ platform="climate", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), ), @@ -408,13 +450,13 @@ DISCOVERY_SCHEMAS = [ platform="climate", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_SETPOINT}, - property={"setpoint"}, + property={THERMOSTAT_SETPOINT_PROPERTY}, type={"number"}, ), absent_values=[ # mode must not be present to prevent dupes ZWaveValueDiscoverySchema( command_class={CommandClass.THERMOSTAT_MODE}, - property={"mode"}, + property={THERMOSTAT_MODE_PROPERTY}, type={"number"}, ), ], @@ -515,7 +557,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.METER, }, type={"number"}, - property={"value"}, + property={VALUE_PROPERTY}, ), data_template=NumericSensorDataTemplate(), ), @@ -541,7 +583,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.BASIC, }, type={"number"}, - property={"currentValue"}, + property={CURRENT_VALUE_PROPERTY}, ), required_values=[ ZWaveValueDiscoverySchema( @@ -549,7 +591,7 @@ DISCOVERY_SCHEMAS = [ CommandClass.BASIC, }, type={"number"}, - property={"targetValue"}, + property={TARGET_VALUE_PROPERTY}, ) ], data_template=NumericSensorDataTemplate(), @@ -567,7 +609,7 @@ DISCOVERY_SCHEMAS = [ hint="barrier_event_signaling_state", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.BARRIER_OPERATOR}, - property={"signalingState"}, + property={SIGNALING_STATE_PROPERTY}, type={"number"}, ), ), @@ -592,13 +634,13 @@ DISCOVERY_SCHEMAS = [ hint="motorized_barrier", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.BARRIER_OPERATOR}, - property={"currentState"}, + property={CURRENT_STATE_PROPERTY}, type={"number"}, ), required_values=[ ZWaveValueDiscoverySchema( command_class={CommandClass.BARRIER_OPERATOR}, - property={"targetState"}, + property={TARGET_STATE_PROPERTY}, type={"number"}, ), ], @@ -640,7 +682,7 @@ DISCOVERY_SCHEMAS = [ hint="Default tone", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.SOUND_SWITCH}, - property={"defaultToneId"}, + property={DEFAULT_TONE_ID_PROPERTY}, type={"number"}, ), required_values=[SIREN_TONE_SCHEMA], @@ -652,7 +694,7 @@ DISCOVERY_SCHEMAS = [ hint="volume", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.SOUND_SWITCH}, - property={"defaultVolume"}, + property={DEFAULT_VOLUME_PROPERTY}, type={"number"}, ), required_values=[SIREN_TONE_SCHEMA], @@ -663,7 +705,7 @@ DISCOVERY_SCHEMAS = [ platform="select", primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.PROTECTION}, - property={"local", "rf"}, + property={LOCAL_PROPERTY, RF_PROPERTY}, type={"number"}, ), ), @@ -671,126 +713,138 @@ DISCOVERY_SCHEMAS = [ @callback -def async_discover_values( - node: ZwaveNode, device: DeviceEntry +def async_discover_node_values( + node: ZwaveNode, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] ) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): - for schema in DISCOVERY_SCHEMAS: - # check manufacturer_id - if ( - schema.manufacturer_id is not None - and value.node.manufacturer_id not in schema.manufacturer_id - ): - continue + # We don't need to rediscover an already processed value_id + if value.value_id in discovered_value_ids[device.id]: + continue + yield from async_discover_single_value(value, device, discovered_value_ids) - # check product_id - if ( - schema.product_id is not None - and value.node.product_id not in schema.product_id - ): - continue - # check product_type - if ( - schema.product_type is not None - and value.node.product_type not in schema.product_type - ): - continue +@callback +def async_discover_single_value( + value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] +) -> Generator[ZwaveDiscoveryInfo, None, None]: + """Run discovery on a single ZWave value and return matching schema info.""" + discovered_value_ids[device.id].add(value.value_id) + for schema in DISCOVERY_SCHEMAS: + # check manufacturer_id + if ( + schema.manufacturer_id is not None + and value.node.manufacturer_id not in schema.manufacturer_id + ): + continue - # check firmware_version_range - if schema.firmware_version_range is not None and ( - ( - schema.firmware_version_range.min is not None - and schema.firmware_version_range.min_ver - > AwesomeVersion(value.node.firmware_version) + # check product_id + if ( + schema.product_id is not None + and value.node.product_id not in schema.product_id + ): + continue + + # check product_type + if ( + schema.product_type is not None + and value.node.product_type not in schema.product_type + ): + continue + + # check firmware_version_range + if schema.firmware_version_range is not None and ( + ( + schema.firmware_version_range.min is not None + and schema.firmware_version_range.min_ver + > AwesomeVersion(value.node.firmware_version) + ) + or ( + schema.firmware_version_range.max is not None + and schema.firmware_version_range.max_ver + < AwesomeVersion(value.node.firmware_version) + ) + ): + continue + + # check firmware_version + if ( + schema.firmware_version is not None + and value.node.firmware_version not in schema.firmware_version + ): + continue + + # check device_class_basic + if not check_device_class( + value.node.device_class.basic, schema.device_class_basic + ): + continue + + # check device_class_generic + if not check_device_class( + value.node.device_class.generic, schema.device_class_generic + ): + continue + + # check device_class_specific + if not check_device_class( + value.node.device_class.specific, schema.device_class_specific + ): + continue + + # check primary value + if not check_value(value, schema.primary_value): + continue + + # check additional required values + if schema.required_values is not None and not all( + any(check_value(val, val_scheme) for val in value.node.values.values()) + for val_scheme in schema.required_values + ): + continue + + # check for values that may not be present + if schema.absent_values is not None and any( + any(check_value(val, val_scheme) for val in value.node.values.values()) + for val_scheme in schema.absent_values + ): + continue + + # resolve helper data from template + resolved_data = None + additional_value_ids_to_watch = set() + if schema.data_template: + try: + resolved_data = schema.data_template.resolve_data(value) + except UnknownValueData as err: + LOGGER.error( + "Discovery for value %s on device '%s' (%s) will be skipped: %s", + value, + device.name_by_user or device.name, + value.node, + err, ) - or ( - schema.firmware_version_range.max is not None - and schema.firmware_version_range.max_ver - < AwesomeVersion(value.node.firmware_version) - ) - ): continue - - # check firmware_version - if ( - schema.firmware_version is not None - and value.node.firmware_version not in schema.firmware_version - ): - continue - - # check device_class_basic - if not check_device_class( - value.node.device_class.basic, schema.device_class_basic - ): - continue - - # check device_class_generic - if not check_device_class( - value.node.device_class.generic, schema.device_class_generic - ): - continue - - # check device_class_specific - if not check_device_class( - value.node.device_class.specific, schema.device_class_specific - ): - continue - - # check primary value - if not check_value(value, schema.primary_value): - continue - - # check additional required values - if schema.required_values is not None and not all( - any(check_value(val, val_scheme) for val in node.values.values()) - for val_scheme in schema.required_values - ): - continue - - # check for values that may not be present - if schema.absent_values is not None and any( - any(check_value(val, val_scheme) for val in node.values.values()) - for val_scheme in schema.absent_values - ): - continue - - # resolve helper data from template - resolved_data = None - additional_value_ids_to_watch = set() - if schema.data_template: - try: - resolved_data = schema.data_template.resolve_data(value) - except UnknownValueData as err: - LOGGER.error( - "Discovery for value %s on device '%s' (%s) will be skipped: %s", - value, - device.name_by_user or device.name, - node, - err, - ) - continue - additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( - resolved_data - ) - - # all checks passed, this value belongs to an entity - yield ZwaveDiscoveryInfo( - node=value.node, - primary_value=value, - assumed_state=schema.assumed_state, - platform=schema.platform, - platform_hint=schema.hint, - 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, + additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( + resolved_data ) - if not schema.allow_multi: - # break out of loop, this value may not be discovered by other schemas/platforms - break + # all checks passed, this value belongs to an entity + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=schema.assumed_state, + platform=schema.platform, + platform_hint=schema.hint, + 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: + # return early since this value may not be discovered by other schemas/platforms + return @callback diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 5c3c49e894f..7b76465d60e 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from zwave_js_server.const import CommandClass @@ -92,9 +92,12 @@ class ZwaveValueID: property_key: str | int | None = None +@dataclass class BaseDiscoverySchemaDataTemplate: """Base class for discovery schema data templates.""" + static_data: Any | None = None + def resolve_data(self, value: ZwaveValue) -> Any: """ Resolve helper class data for a discovered value. @@ -141,11 +144,13 @@ class BaseDiscoverySchemaDataTemplate: class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave JS Climate entities with dynamic current temps.""" - lookup_table: dict[str | int, ZwaveValueID] - dependent_value: ZwaveValueID + lookup_table: dict[str | int, ZwaveValueID] = field(default_factory=dict) + dependent_value: ZwaveValueID | None = None def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: """Resolve helper class data for a discovered value.""" + if not self.lookup_table or not self.dependent_value: + raise ValueError("Invalid discovery data template") data: dict[str, Any] = { "lookup_table": {}, "dependent_value": self._get_value_from_id( @@ -201,7 +206,7 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): ): return ENTITY_DESC_KEY_TOTAL_INCREASING # We do this because even though these are power scales, they don't meet - # the unit requirements for the energy power class. + # the unit requirements for the power device class. if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: return ENTITY_DESC_KEY_MEASUREMENT diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 793eaa435d5..f9bba52c95b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id +from .migrate import async_add_migration_entity_value LOGGER = logging.getLogger(__name__) @@ -109,6 +110,11 @@ class ZWaveBaseEntity(Entity): ) ) + # Add legacy Z-Wave migration data. + await async_add_migration_entity_value( + self.hass, self.config_entry, self.entity_id, self.info + ) + def generate_name( self, include_value_name: bool = False, diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 71f483c548f..6ee709893cb 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -5,6 +5,7 @@ import math from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_VALUE_PROPERTY from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, @@ -59,7 +60,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): async def async_set_percentage(self, percentage: int | None) -> None: """Set the speed percentage of the fan.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) if percentage is None: # Value 255 tells device to return to previous value @@ -83,7 +84,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - target_value = self.get_zwave_value("targetValue") + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) await self.info.node.async_set_value(target_value, 0) @property diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 4744c7f9fc1..4894c40b8ae 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,7 +1,8 @@ """Helper functions for Z-Wave JS integration.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 0857b43e4ee..caba0f5de36 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -5,8 +5,24 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass -from zwave_js_server.const.command_class.color_switch import ColorComponent +from zwave_js_server.const import ( + TARGET_VALUE_PROPERTY, + TRANSITION_DURATION_OPTION, + CommandClass, +) +from zwave_js_server.const.command_class.color_switch import ( + COLOR_SWITCH_COMBINED_AMBER, + COLOR_SWITCH_COMBINED_BLUE, + COLOR_SWITCH_COMBINED_COLD_WHITE, + COLOR_SWITCH_COMBINED_CYAN, + COLOR_SWITCH_COMBINED_GREEN, + COLOR_SWITCH_COMBINED_PURPLE, + COLOR_SWITCH_COMBINED_RED, + COLOR_SWITCH_COMBINED_WARM_WHITE, + CURRENT_COLOR_PROPERTY, + TARGET_COLOR_PROPERTY, + ColorComponent, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -35,18 +51,16 @@ from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) MULTI_COLOR_MAP = { - ColorComponent.WARM_WHITE: "warmWhite", - ColorComponent.COLD_WHITE: "coldWhite", - ColorComponent.RED: "red", - ColorComponent.GREEN: "green", - ColorComponent.BLUE: "blue", - ColorComponent.AMBER: "amber", - ColorComponent.CYAN: "cyan", - ColorComponent.PURPLE: "purple", + ColorComponent.WARM_WHITE: COLOR_SWITCH_COMBINED_WARM_WHITE, + ColorComponent.COLD_WHITE: COLOR_SWITCH_COMBINED_COLD_WHITE, + ColorComponent.RED: COLOR_SWITCH_COMBINED_RED, + ColorComponent.GREEN: COLOR_SWITCH_COMBINED_GREEN, + ColorComponent.BLUE: COLOR_SWITCH_COMBINED_BLUE, + ColorComponent.AMBER: COLOR_SWITCH_COMBINED_AMBER, + ColorComponent.CYAN: COLOR_SWITCH_COMBINED_CYAN, + ColorComponent.PURPLE: COLOR_SWITCH_COMBINED_PURPLE, } -TRANSITION_DURATION = "transitionDuration" - async def async_setup_entry( hass: HomeAssistant, @@ -100,12 +114,12 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._min_mireds = 153 # 6500K as a safe default self._max_mireds = 370 # 2700K as a safe default self._warm_white = self.get_zwave_value( - "targetColor", + TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.WARM_WHITE, ) self._cold_white = self.get_zwave_value( - "targetColor", + TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE, ) @@ -113,10 +127,12 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # get additional (optional) values and set features self._target_brightness = self.get_zwave_value( - "targetValue", add_to_watched_value_ids=False + TARGET_VALUE_PROPERTY, add_to_watched_value_ids=False ) self._target_color = self.get_zwave_value( - "targetColor", CommandClass.SWITCH_COLOR, add_to_watched_value_ids=False + TARGET_COLOR_PROPERTY, + CommandClass.SWITCH_COLOR, + add_to_watched_value_ids=False, ) self._calculate_color_values() @@ -133,12 +149,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._attr_supported_features = 0 self.supports_brightness_transition = bool( self._target_brightness is not None - and TRANSITION_DURATION + and TRANSITION_DURATION_OPTION 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 + and TRANSITION_DURATION_OPTION + in self._target_color.metadata.value_change_options ) if self.supports_brightness_transition or self.supports_color_transition: @@ -284,9 +301,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self.supports_color_transition: if transition is not None: - zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} + zwave_transition = {TRANSITION_DURATION_OPTION: f"{int(transition)}s"} else: - zwave_transition = {TRANSITION_DURATION: "default"} + zwave_transition = {TRANSITION_DURATION_OPTION: "default"} colors_dict = {} for color, value in colors.items(): @@ -312,9 +329,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_transition = None if self.supports_brightness_transition: if transition is not None: - zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} + zwave_transition = {TRANSITION_DURATION_OPTION: f"{int(transition)}s"} else: - zwave_transition = {TRANSITION_DURATION: "default"} + zwave_transition = {TRANSITION_DURATION_OPTION: "default"} # setting a value requires setting targetValue await self.info.node.async_set_value( @@ -328,34 +345,34 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # to find out what colors are supported # as this is a simple lookup by key, this not heavy red_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.RED.value, ) green_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.GREEN.value, ) blue_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.BLUE.value, ) ww_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.WARM_WHITE.value, ) cw_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE.value, ) # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 combined_color_val = self.get_zwave_value( - "currentColor", + CURRENT_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, value_property_key=None, ) @@ -370,9 +387,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # RGB support if red_val and green_val and blue_val: # prefer values from the multicolor property - red = multi_color.get("red", red_val.value) - green = multi_color.get("green", green_val.value) - blue = multi_color.get("blue", blue_val.value) + red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) + green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) + blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) self._supports_color = True if None not in (red, green, blue): # convert to HS @@ -383,8 +400,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # color temperature support if ww_val and cw_val: self._supports_color_temp = True - warm_white = multi_color.get("warmWhite", ww_val.value) - cold_white = multi_color.get("coldWhite", cw_val.value) + warm_white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) + cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) # Calculate color temps based on whites if cold_white or warm_white: self._color_temp = round( @@ -398,14 +415,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: self._supports_rgbw = True - white = multi_color.get("warmWhite", ww_val.value) + white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = COLOR_MODE_RGBW # only one white channel (cool white) = rgbw support elif cw_val: self._supports_rgbw = True - white = multi_color.get("coldWhite", cw_val.value) + white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = COLOR_MODE_RGBW diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 0f2a0862d7f..d70b6ef2009 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -25,7 +25,12 @@ 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, DOMAIN +from .const import ( + DATA_CLIENT, + DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_USERCODE, +) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -42,9 +47,6 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { }, } -SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" -SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index c7b2b35837b..50e0a039488 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.30.0"], + "requirements": ["zwave-js-server-python==0.31.3"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py index 397f7efba24..6598f26d45c 100644 --- a/homeassistant/components/zwave_js/migrate.py +++ b/homeassistant/components/zwave_js/migrate.py @@ -1,27 +1,355 @@ """Functions used to migrate unique IDs for Z-Wave JS entities.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import logging +from typing import TypedDict, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import ( + DeviceEntry, + async_get as async_get_device_registry, +) from homeassistant.helpers.entity_registry import ( EntityRegistry, RegistryEntry, async_entries_for_device, + async_get as async_get_entity_registry, ) +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo -from .helpers import get_unique_id +from .helpers import get_device_id, get_unique_id _LOGGER = logging.getLogger(__name__) +LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration" +MIGRATED = "migrated" +STORAGE_WRITE_DELAY = 30 +STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration" +STORAGE_VERSION = 1 + +NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME = { + "Smoke": "Smoke Alarm", + "Carbon Monoxide": "CO Alarm", + "Carbon Dioxide": "CO2 Alarm", + "Heat": "Heat Alarm", + "Flood": "Water Alarm", + "Access Control": "Access Control", + "Burglar": "Home Security", + "Power Management": "Power Management", + "System": "System", + "Emergency": "Siren", + "Clock": "Clock", + "Appliance": "Appliance", + "HomeHealth": "Home Health", +} + +SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME = { + "Temperature": "Air temperature", + "General": "General purpose", + "Luminance": "Illuminance", + "Power": "Power", + "Relative Humidity": "Humidity", + "Velocity": "Velocity", + "Direction": "Direction", + "Atmospheric Pressure": "Atmospheric pressure", + "Barometric Pressure": "Barometric pressure", + "Solar Radiation": "Solar radiation", + "Dew Point": "Dew point", + "Rain Rate": "Rain rate", + "Tide Level": "Tide level", + "Weight": "Weight", + "Voltage": "Voltage", + "Current": "Current", + "CO2 Level": "Carbon dioxide (CO₂) level", + "Air Flow": "Air flow", + "Tank Capacity": "Tank capacity", + "Distance": "Distance", + "Angle Position": "Angle position", + "Rotation": "Rotation", + "Water Temperature": "Water temperature", + "Soil Temperature": "Soil temperature", + "Seismic Intensity": "Seismic Intensity", + "Seismic Magnitude": "Seismic magnitude", + "Ultraviolet": "Ultraviolet", + "Electrical Resistivity": "Electrical resistivity", + "Electrical Conductivity": "Electrical conductivity", + "Loudness": "Loudness", + "Moisture": "Moisture", +} + +CC_ID_LABEL_TO_PROPERTY = { + 49: SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME, + 113: NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME, +} + + +class ZWaveMigrationData(TypedDict): + """Represent the Z-Wave migration data dict.""" + + node_id: int + node_instance: int + command_class: int + command_class_label: str + value_index: int + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +class ZWaveJSMigrationData(TypedDict): + """Represent the Z-Wave JS migration data dict.""" + + node_id: int + endpoint_index: int + command_class: int + value_property_name: str + value_property_key_name: str | None + value_id: str + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +@dataclass +class LegacyZWaveMappedData: + """Represent the mapped data between Z-Wave and Z-Wave JS.""" + + entity_entries: dict[str, ZWaveMigrationData] = field(default_factory=dict) + device_entries: dict[str, str] = field(default_factory=dict) + + +async def async_add_migration_entity_value( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_id: str, + discovery_info: ZwaveDiscoveryInfo, +) -> None: + """Add Z-Wave JS entity value for legacy Z-Wave migration.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + migration_handler.add_entity_value(config_entry, entity_id, discovery_info) + + +async def async_get_migration_data( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, ZWaveJSMigrationData]: + """Return Z-Wave JS migration data.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + return await migration_handler.get_data(config_entry) + + +@singleton(LEGACY_ZWAVE_MIGRATION) +async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration: + """Return legacy Z-Wave migration handler.""" + migration_handler = LegacyZWaveMigration(hass) + await migration_handler.load_data() + return migration_handler + + +class LegacyZWaveMigration: + """Handle the migration from zwave to zwave_js.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Set up migration instance.""" + self._hass = hass + self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, dict[str, ZWaveJSMigrationData]] = {} + + async def load_data(self) -> None: + """Load Z-Wave JS migration data.""" + stored = cast(dict, await self._store.async_load()) + if stored: + self._data = stored + + @callback + def save_data( + self, config_entry_id: str, entity_id: str, data: ZWaveJSMigrationData + ) -> None: + """Save Z-Wave JS migration data.""" + if config_entry_id not in self._data: + self._data[config_entry_id] = {} + self._data[config_entry_id][entity_id] = data + self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, ZWaveJSMigrationData]]: + """Return data to save.""" + return self._data + + @callback + def add_entity_value( + self, + config_entry: ConfigEntry, + entity_id: str, + discovery_info: ZwaveDiscoveryInfo, + ) -> None: + """Add info for one entity and Z-Wave JS value.""" + ent_reg = async_get_entity_registry(self._hass) + dev_reg = async_get_device_registry(self._hass) + + node = discovery_info.node + primary_value = discovery_info.primary_value + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + device_identifier = get_device_id(node.client, node) + device_entry = dev_reg.async_get_device({device_identifier}, set()) + assert device_entry + + # Normalize unit of measurement. + if unit := entity_entry.unit_of_measurement: + unit = unit.lower() + if unit == "": + unit = None + + data: ZWaveJSMigrationData = { + "node_id": node.node_id, + "endpoint_index": node.index, + "command_class": primary_value.command_class, + "value_property_name": primary_value.property_name, + "value_property_key_name": primary_value.property_key_name, + "value_id": primary_value.value_id, + "device_id": device_entry.id, + "domain": entity_entry.domain, + "entity_id": entity_id, + "unique_id": entity_entry.unique_id, + "unit_of_measurement": unit, + } + + self.save_data(config_entry.entry_id, entity_id, data) + + async def get_data( + self, config_entry: ConfigEntry + ) -> dict[str, ZWaveJSMigrationData]: + """Return Z-Wave JS migration data for a config entry.""" + await self.load_data() + data = self._data.get(config_entry.entry_id) + return data or {} + + +@callback +def async_map_legacy_zwave_values( + zwave_data: dict[str, ZWaveMigrationData], + zwave_js_data: dict[str, ZWaveJSMigrationData], +) -> LegacyZWaveMappedData: + """Map Z-Wave node values onto Z-Wave JS node values.""" + migration_map = LegacyZWaveMappedData() + zwave_proc_data: dict[ + tuple[int, int, int, str, str | None, str | None], + ZWaveMigrationData | None, + ] = {} + zwave_js_proc_data: dict[ + tuple[int, int, int, str, str | None, str | None], + ZWaveJSMigrationData | None, + ] = {} + + for zwave_item in zwave_data.values(): + zwave_js_property_name = CC_ID_LABEL_TO_PROPERTY.get( + zwave_item["command_class"], {} + ).get(zwave_item["command_class_label"]) + item_id = ( + zwave_item["node_id"], + zwave_item["command_class"], + zwave_item["node_instance"] - 1, + zwave_item["domain"], + zwave_item["unit_of_measurement"], + zwave_js_property_name, + ) + + # Filter out duplicates that are not resolvable. + if item_id in zwave_proc_data: + zwave_proc_data[item_id] = None + continue + + zwave_proc_data[item_id] = zwave_item + + for zwave_js_item in zwave_js_data.values(): + # Only identify with property name if there is a command class label map. + if zwave_js_item["command_class"] in CC_ID_LABEL_TO_PROPERTY: + zwave_js_property_name = zwave_js_item["value_property_name"] + else: + zwave_js_property_name = None + item_id = ( + zwave_js_item["node_id"], + zwave_js_item["command_class"], + zwave_js_item["endpoint_index"], + zwave_js_item["domain"], + zwave_js_item["unit_of_measurement"], + zwave_js_property_name, + ) + + # Filter out duplicates that are not resolvable. + if item_id in zwave_js_proc_data: + zwave_js_proc_data[item_id] = None + continue + + zwave_js_proc_data[item_id] = zwave_js_item + + for item_id, zwave_entry in zwave_proc_data.items(): + zwave_js_entry = zwave_js_proc_data.pop(item_id, None) + + if zwave_entry is None or zwave_js_entry is None: + continue + + migration_map.entity_entries[zwave_js_entry["entity_id"]] = zwave_entry + migration_map.device_entries[zwave_js_entry["device_id"]] = zwave_entry[ + "device_id" + ] + + return migration_map + + +async def async_migrate_legacy_zwave( + hass: HomeAssistant, + zwave_config_entry: ConfigEntry, + zwave_js_config_entry: ConfigEntry, + migration_map: LegacyZWaveMappedData, +) -> None: + """Perform Z-Wave to Z-Wave JS migration.""" + dev_reg = async_get_device_registry(hass) + for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items(): + zwave_device_entry = dev_reg.async_get(zwave_device_id) + if not zwave_device_entry: + continue + dev_reg.async_update_device( + zwave_js_device_id, + area_id=zwave_device_entry.area_id, + name_by_user=zwave_device_entry.name_by_user, + ) + + ent_reg = async_get_entity_registry(hass) + for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items(): + zwave_entity_id = zwave_entry["entity_id"] + entity_entry = ent_reg.async_get(zwave_entity_id) + if not entity_entry: + continue + ent_reg.async_remove(zwave_entity_id) + ent_reg.async_update_entity( + zwave_js_entity_id, + new_entity_id=entity_entry.entity_id, + name=entity_entry.name, + icon=entity_entry.icon, + ) + + await hass.config_entries.async_remove(zwave_config_entry.entry_id) + + updates = { + **zwave_js_config_entry.data, + MIGRATED: True, + } + hass.config_entries.async_update_entry(zwave_js_config_entry, data=updates) + @dataclass class ValueID: diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 675a396fb7b..16434b51108 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -2,6 +2,7 @@ from __future__ import annotations from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import TARGET_VALUE_PROPERTY from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -52,7 +53,7 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): if self.info.primary_value.metadata.writeable: self._target_value = self.info.primary_value else: - self._target_value = self.get_zwave_value("targetValue") + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) # Entity class attributes self._attr_name = self.generate_name( diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index fae87fd24de..15223419ced 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -1,8 +1,10 @@ """Support for Z-Wave controls using the select platform.""" from __future__ import annotations +from typing import Dict, cast + from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass +from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity @@ -30,6 +32,10 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "Default tone": entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + elif info.platform_hint == "multilevel_switch": + entities.append( + ZwaveMultilevelSwitchSelectEntity(config_entry, client, info) + ) else: entities.append(ZwaveSelectEntity(config_entry, client, info)) async_add_entities(entities) @@ -126,3 +132,37 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): if val == option ) await self.info.node.async_set_value(self.info.primary_value, int(key)) + + +class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave Multilevel Switch CC select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveSelectEntity entity.""" + super().__init__(config_entry, client, info) + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + assert self.info.platform_data_template + self._lookup_map = cast( + Dict[int, str], self.info.platform_data_template.static_data + ) + + # Entity class attributes + self._attr_options = list(self._lookup_map.values()) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self.info.primary_value.value is None: + return None + return str( + self._lookup_map.get( + int(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + key = next(key for key, val in self._lookup_map.items() if val == option) + await self.info.node.async_set_value(self._target_value, int(key)) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 6532da8a5e0..7da7ba9ab9b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -8,7 +8,7 @@ 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.const import CommandClass, ConfigurationValueType, NodeStatus from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, @@ -80,6 +80,14 @@ from .helpers import get_device_id LOGGER = logging.getLogger(__name__) +STATUS_ICON: dict[NodeStatus, str] = { + NodeStatus.ALIVE: "mdi:heart-pulse", + NodeStatus.ASLEEP: "mdi:sleep", + NodeStatus.AWAKE: "mdi:eye", + NodeStatus.DEAD: "mdi:robot-dead", + NodeStatus.UNKNOWN: "mdi:help-rhombus", +} + @dataclass class ZwaveSensorEntityDescription(SensorEntityDescription): @@ -105,7 +113,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ZwaveSensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_MEASUREMENT, ), @@ -471,6 +479,7 @@ class ZWaveNodeStatusSensor(SensorEntity): async def async_poll_value(self, _: bool) -> None: """Poll a value.""" + # pylint: disable=no-self-use raise ValueError("There is no value to poll for this entity") @callback @@ -479,6 +488,11 @@ class ZWaveNodeStatusSensor(SensorEntity): self._attr_native_value = self.node.status.name.lower() self.async_write_ha_state() + @property + def icon(self) -> str | None: + """Icon of the entity.""" + return STATUS_ICON[self.node.status] + async def async_added_to_hass(self) -> None: """Call when entity is added.""" # Add value_changed callbacks. diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 431f88a875d..08841465321 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -59,27 +59,6 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: ) -# Validates that a bitmask is provided in hex form and converts it to decimal -# int equivalent since that's what the library uses -BITMASK_SCHEMA = vol.All( - cv.string, - vol.Lower, - vol.Match( - r"^(0x)?[0-9a-f]+$", - msg="Must provide an integer (e.g. 255) or a bitmask in hex form (e.g. 0xff)", - ), - lambda value: int(value, 16), -) - -VALUE_SCHEMA = vol.Any( - bool, - vol.Coerce(int), - vol.Coerce(float), - BITMASK_SCHEMA, - cv.string, -) - - class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" @@ -198,10 +177,10 @@ class ZWaveServices: vol.Coerce(int), cv.string ), vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any( - vol.Coerce(int), BITMASK_SCHEMA + vol.Coerce(int), const.BITMASK_SCHEMA ), vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( - vol.Coerce(int), BITMASK_SCHEMA, cv.string + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string ), }, cv.has_at_least_one_key( @@ -232,8 +211,10 @@ class ZWaveServices: vol.Coerce(int), { vol.Any( - vol.Coerce(int), BITMASK_SCHEMA, cv.string - ): vol.Any(vol.Coerce(int), BITMASK_SCHEMA, cv.string) + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string + ): vol.Any( + vol.Coerce(int), const.BITMASK_SCHEMA, cv.string + ) }, ), }, @@ -284,9 +265,11 @@ class ZWaveServices: vol.Coerce(int), str ), vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), - vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, + vol.Required(const.ATTR_VALUE): const.VALUE_SCHEMA, vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, - vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, + vol.Optional(const.ATTR_OPTIONS): { + cv.string: const.VALUE_SCHEMA + }, }, cv.has_at_least_one_key( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID @@ -319,8 +302,10 @@ class ZWaveServices: vol.Coerce(int), str ), 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.Required(const.ATTR_VALUE): const.VALUE_SCHEMA, + vol.Optional(const.ATTR_OPTIONS): { + cv.string: const.VALUE_SCHEMA + }, }, vol.Any( cv.has_at_least_one_key( @@ -359,6 +344,7 @@ class ZWaveServices: async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" + # pylint: disable=no-self-use nodes = service.data[const.ATTR_NODES] property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) @@ -386,6 +372,7 @@ class ZWaveServices: self, service: ServiceCall ) -> None: """Bulk set multiple partial config values on a node.""" + # pylint: disable=no-self-use nodes = service.data[const.ATTR_NODES] property_ = service.data[const.ATTR_CONFIG_PARAMETER] new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -420,6 +407,7 @@ class ZWaveServices: async def async_set_value(self, service: ServiceCall) -> None: """Set a value on a node.""" + # pylint: disable=no-self-use nodes = service.data[const.ATTR_NODES] command_class = service.data[const.ATTR_COMMAND_CLASS] property_ = service.data[const.ATTR_PROPERTY] @@ -496,5 +484,6 @@ class ZWaveServices: async def async_ping(self, service: ServiceCall) -> None: """Ping node(s).""" + # pylint: disable=no-self-use nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] await asyncio.gather(*(node.async_ping() for node in nodes)) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d0bdec1a80c..1446c1fc7aa 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -24,7 +24,10 @@ "title": "Enter the Z-Wave JS add-on configuration", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", - "network_key": "Network Key" + "s0_legacy_key": "S0 Key (Legacy)", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", + "s2_access_control_key": "S2 Access Control Key" } }, "start_addon": { @@ -67,7 +70,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" @@ -76,12 +81,17 @@ "title": "Enter the Z-Wave JS add-on configuration", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", - "network_key": "Network Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", + "s2_access_control_key": "S2 Access Control Key", "log_level": "Log level", "emulate_hardware": "Emulate Hardware" } }, - "start_addon": { "title": "The Z-Wave JS add-on is starting." } + "start_addon": { + "title": "The Z-Wave JS add-on is starting." + } }, "error": { "invalid_ws_url": "Invalid websocket URL", @@ -118,6 +128,15 @@ "node_status": "Node status", "config_parameter": "Config parameter {subtype} value", "value": "Current value of a Z-Wave Value" + }, + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_value": "Set value of a Z-Wave Value", + "refresh_value": "Refresh the value(s) for {entity_name}", + "ping": "Ping device", + "reset_meter": "Reset meters on {subtype}" } } } diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 44aa3a5566f..390ba6eaf0b 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 TARGET_VALUE_PROPERTY from zwave_js_server.const.command_class.barrier_operator import ( BarrierEventSignalingSubsystemState, ) @@ -55,6 +56,14 @@ async def async_setup_entry( class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): """Representation of a Z-Wave switch.""" + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the switch.""" + super().__init__(config_entry, client, info) + + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + @property def is_on(self) -> bool | None: # type: ignore """Return a boolean for the state of the switch.""" @@ -65,15 +74,13 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - target_value = self.get_zwave_value("targetValue") - if target_value is not None: - await self.info.node.async_set_value(target_value, True) + if self._target_value is not None: + await self.info.node.async_set_value(self._target_value, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - target_value = self.get_zwave_value("targetValue") - if target_value is not None: - await self.info.node.async_set_value(target_value, False) + if self._target_value is not None: + await self.info.node.async_set_value(self._target_value, False) class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index ac18c44b489..d0861d44f89 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -27,7 +27,11 @@ "configure_addon": { "data": { "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" + "s0_legacy_key": "Clau d'S0 (est\u00e0ndard)", + "s2_access_control_key": "Clau de control d'acc\u00e9s d'S2", + "s2_authenticated_key": "Clau d'S2 autenticat", + "s2_unauthenticated_key": "Clau d'S2 no autenticat", + "usb_path": "Ruta del dispositiu USB" }, "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" }, @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Esborra codi d'usuari de {entity_name}", + "ping": "Sondeja dispositiu", + "refresh_value": "Actualitza el/s valor/s de {entity_name}", + "reset_meter": "Reinicialitza comptadors de {subtype}", + "set_config_parameter": "Estableix el valor del par\u00e0metre de configuraci\u00f3 {subtype}", + "set_lock_usercode": "Estableix codi d'usuari a {entity_name}", + "set_value": "Estableix el valor d'un valor Z-Wave" + }, "condition_type": { "config_parameter": "Configura el valor del par\u00e0metre {subtype}", "node_status": "Estat del node", @@ -100,7 +113,11 @@ "emulate_hardware": "Emula maquinari", "log_level": "Nivell dels registres", "network_key": "Clau de xarxa", - "usb_path": "Ruta del port USB del dispositiu" + "s0_legacy_key": "Clau d'S0 (est\u00e0ndard)", + "s2_access_control_key": "Clau de control d'acc\u00e9s d'S2", + "s2_authenticated_key": "Clau d'S2 autenticat", + "s2_unauthenticated_key": "Clau d'S2 no autenticat", + "usb_path": "Ruta del dispositiu USB" }, "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" }, diff --git a/homeassistant/components/zwave_js/translations/cs.json b/homeassistant/components/zwave_js/translations/cs.json index 05efdb8e5ff..013488d113f 100644 --- a/homeassistant/components/zwave_js/translations/cs.json +++ b/homeassistant/components/zwave_js/translations/cs.json @@ -9,6 +9,7 @@ "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, + "flow_title": "{name}", "step": { "configure_addon": { "data": { diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index 8d9634c3f46..8bdf7a78237 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Netzwerk-Schl\u00fcssel", + "s0_legacy_key": "S0 Schl\u00fcssel (Legacy)", + "s2_access_control_key": "S2 Zugangskontrollschl\u00fcssel", + "s2_authenticated_key": "S2 Authentifizierter Schl\u00fcssel", + "s2_unauthenticated_key": "S2 Nicht authentifizierter Schl\u00fcssel", "usb_path": "USB-Ger\u00e4te-Pfad" }, "title": "Gib die Konfiguration des Z-Wave JS Add-ons ein" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Nutzercode f\u00fcr {entity_name} l\u00f6schen", + "ping": "Ger\u00e4t anpingen", + "refresh_value": "Aktualisieren der Wert(e) f\u00fcr {entity_name}", + "reset_meter": "Z\u00e4hler von {subtype} zur\u00fccksetzen", + "set_config_parameter": "Wert des Konfigurationsparameters {subtype} festlegen", + "set_lock_usercode": "Einen Nutzercode f\u00fcr {entity_name} festlegen", + "set_value": "Wert eines Z-Wave-Werts einstellen" + }, "condition_type": { "config_parameter": "Wert des Konfigurationsparameters {subtype}", "node_status": "Status des Knotens", @@ -100,6 +113,10 @@ "emulate_hardware": "Hardware emulieren", "log_level": "Protokollstufe", "network_key": "Netzwerkschl\u00fcssel", + "s0_legacy_key": "S0 Schl\u00fcssel (Legacy)", + "s2_access_control_key": "S2 Zugangskontrollschl\u00fcssel", + "s2_authenticated_key": "S2 Authentifizierter Schl\u00fcssel", + "s2_unauthenticated_key": "S2 Nicht authentifizierter Schl\u00fcssel", "usb_path": "USB-Ger\u00e4te-Pfad" }, "title": "Gib die Konfiguration des Z-Wave JS-Add-ons ein" diff --git a/homeassistant/components/zwave_js/translations/el.json b/homeassistant/components/zwave_js/translations/el.json new file mode 100644 index 00000000000..21ba61a6af1 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/el.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "discovery_requires_supervisor": "\u0397 \u03b1\u03bd\u03b1\u03ba\u03ac\u03bb\u03c5\u03c8\u03b7 \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03c4\u03bf\u03bd \u03b5\u03c0\u03cc\u03c0\u03c4\u03b7.", + "not_zwave_device": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03ba\u03b1\u03bb\u03cd\u03c6\u03b8\u03b7\u03ba\u03b5 \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Z-Wave." + }, + "flow_title": "{name}", + "step": { + "usb_confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} \u03bc\u03b5 \u03c4\u03bf \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf Z-Wave JS;" + } + } + }, + "device_automation": { + "trigger_type": { + "zwave_js.value_updated.config_parameter": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03c4\u03b9\u03bc\u03ae\u03c2 \u03c3\u03c4\u03b7\u03bd \u03c0\u03b1\u03c1\u03ac\u03bc\u03b5\u03c4\u03c1\u03bf config {subtype}", + "zwave_js.value_updated.value": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03c4\u03b9\u03bc\u03ae\u03c2 \u03c3\u03b5 \u03c4\u03b9\u03bc\u03ae 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 8ba33702d1d..46650ca5439 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Network Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "USB Device Path" }, "title": "Enter the Z-Wave JS add-on configuration" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "ping": "Ping device", + "refresh_value": "Refresh the value(s) for {entity_name}", + "reset_meter": "Reset meters on {subtype}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_value": "Set value of a Z-Wave Value" + }, "condition_type": { "config_parameter": "Config parameter {subtype} value", "node_status": "Node status", @@ -100,6 +113,10 @@ "emulate_hardware": "Emulate Hardware", "log_level": "Log level", "network_key": "Network Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "USB Device Path" }, "title": "Enter the Z-Wave JS add-on configuration" diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index 99ffee8270d..e1a9cd081ba 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -16,6 +16,7 @@ "invalid_ws_url": "URL de websocket no v\u00e1lida", "unknown": "Error inesperado" }, + "flow_title": "{name}", "progress": { "install_addon": "Espera mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Puede tardar varios minutos.", "start_addon": "Espere mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar unos segundos." @@ -75,7 +76,7 @@ "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." + "different_device": "El dispositivo USB conectado no es el mismo que el configurado anteriormente para esta entrada de configuraci\u00f3n. Por favor, crea una nueva entrada de configuraci\u00f3n para el nuevo dispositivo." }, "error": { "cannot_connect": "No se pudo conectar", @@ -83,8 +84,8 @@ "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." + "install_addon": "Por favor, espera mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Esto puede tardar varios minutos.", + "start_addon": "Por favor, espera mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar algunos segundos." }, "step": { "configure_addon": { @@ -98,6 +99,14 @@ }, "install_addon": { "title": "La instalaci\u00f3n del complemento Z-Wave JS ha comenzado" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "title": "Selecciona el m\u00e9todo de conexi\u00f3n" } } }, diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index efed557fe73..10a813aad85 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "V\u00f5rgu v\u00f5ti", + "s0_legacy_key": "S0 vana t\u00fc\u00fcpi v\u00f5ti", + "s2_access_control_key": "S2 juurdep\u00e4\u00e4suv\u00f5ti", + "s2_authenticated_key": "Autenditud S2 v\u00f5ti", + "s2_unauthenticated_key": "Autentimata S2 v\u00f5ti", "usb_path": "USB-seadme asukoha rada" }, "title": "Sisesta Z-Wave JS lisandmooduli seaded" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Kustutaolemi {entity_name} kasutajakood", + "ping": "K\u00fcsitle seadet", + "refresh_value": "Olemi {entity_name} v\u00e4\u00e4rtuste v\u00e4rskendamine", + "reset_meter": "L\u00e4htesta arvesti {subtype}", + "set_config_parameter": "Seadeparameetri {subtype} v\u00e4\u00e4rtuse omistamine", + "set_lock_usercode": "Olemi {entity_name} kasutaja koodi m\u00e4\u00e4ramine", + "set_value": "Z-Wave v\u00e4\u00e4rtuse m\u00e4\u00e4ramine" + }, "condition_type": { "config_parameter": "Seadeparameeteri {subtype} v\u00e4\u00e4rtus", "node_status": "S\u00f5lme olek", @@ -100,6 +113,10 @@ "emulate_hardware": "Riistvara emuleerimine", "log_level": "Logimise tase", "network_key": "V\u00f5rgu v\u00f5ti", + "s0_legacy_key": "S0 vana t\u00fc\u00fcpi v\u00f5ti", + "s2_access_control_key": "S2 juurdep\u00e4\u00e4suv\u00f5ti", + "s2_authenticated_key": "Autenditud S2 v\u00f5ti", + "s2_unauthenticated_key": "Autentimata S2 v\u00f5ti", "usb_path": "USB-seadme asukoha rada" }, "title": "Sisesta Z-Wave JS lisandmooduli seaded" diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index 1e51e97044a..bca57b9da68 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -6,16 +6,19 @@ "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": "Le p\u00e9riph\u00e9rique est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", - "cannot_connect": "\u00c9chec de la connexion " + "cannot_connect": "\u00c9chec de connexion", + "discovery_requires_supervisor": "La d\u00e9couverte n\u00e9cessite le superviseur.", + "not_zwave_device": "L'appareil d\u00e9couvert n'est pas un appareil Z-Wave." }, "error": { "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. V\u00e9rifiez la configuration.", - "cannot_connect": "Erreur de connection", + "cannot_connect": "\u00c9chec de connexion", "invalid_ws_url": "URL websocket invalide", "unknown": "Erreur inattendue" }, + "flow_title": "{name}", "progress": { "install_addon": "Veuillez patienter pendant l'installation du module compl\u00e9mentaire Z-Wave JS. Cela peut prendre plusieurs minutes.", "start_addon": "Veuillez patienter pendant le d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. Cela peut prendre quelques secondes." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Le module compl\u00e9mentaire Z-Wave JS est d\u00e9marr\u00e9." + }, + "usb_confirm": { + "description": "Voulez-vous configurer {name} avec le plugin Z-Wave JS ?" } } }, @@ -63,7 +69,9 @@ "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" + "state.node_status": "Changement de statut du noeud", + "zwave_js.value_updated.config_parameter": "Changement de valeur sur le param\u00e8tre de configuration {subtype}", + "zwave_js.value_updated.value": "Changement de valeur sur une valeur Z-Wave JS" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/he.json b/homeassistant/components/zwave_js/translations/he.json index fae03188b81..041e1cafec6 100644 --- a/homeassistant/components/zwave_js/translations/he.json +++ b/homeassistant/components/zwave_js/translations/he.json @@ -64,5 +64,6 @@ "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?" } } - } + }, + "title": "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 74a8b9db316..715881fb329 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -7,8 +7,10 @@ "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" + "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van.", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "discovery_requires_supervisor": "A felfedez\u00e9shez a fel\u00fcgyel\u0151re van sz\u00fcks\u00e9g.", + "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z." }, "error": { "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt. Ellen\u0151rizze a konfigur\u00e1ci\u00f3t.", @@ -16,6 +18,7 @@ "invalid_ws_url": "\u00c9rv\u00e9nytelen websocket URL", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, + "flow_title": "{name}", "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." @@ -24,6 +27,10 @@ "configure_addon": { "data": { "network_key": "H\u00e1l\u00f3zati kulcs", + "s0_legacy_key": "S0 kulcs (r\u00e9gi)", + "s2_access_control_key": "S2 Hozz\u00e1f\u00e9r\u00e9s kulcs", + "s2_authenticated_key": "S2 hiteles\u00edtett kulcs", + "s2_unauthenticated_key": "S2 nem hiteles\u00edtett 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" @@ -43,15 +50,27 @@ "data": { "use_addon": "Haszn\u00e1ld a Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt" }, - "description": "Szeretn\u00e9d haszn\u00e1lni az Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt?", - "title": "V\u00e1laszd ki a csatlakoz\u00e1si m\u00f3dot" + "description": "Szeretn\u00e9 haszn\u00e1lni az Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt?", + "title": "V\u00e1lassza ki a csatlakoz\u00e1si m\u00f3dot" }, "start_addon": { "title": "Indul a Z-Wave JS b\u0151v\u00edtm\u00e9ny." + }, + "usb_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a(z) {name} alkalmaz\u00e1st a Z-Wave JS b\u0151v\u00edtm\u00e9nnyel?" } } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "{entity_name} felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1nak t\u00f6rl\u00e9se", + "ping": "Eszk\u00f6z pinget\u00e9se", + "refresh_value": "{entity_name} \u00e9rt\u00e9keinek friss\u00edt\u00e9se", + "reset_meter": "{subtype} m\u00e9r\u00e9sek alaphelyzetbe \u00e1ll\u00edt\u00e1sa", + "set_config_parameter": "{subtype} konfigur\u00e1ci\u00f3s param\u00e9ter \u00e9rt\u00e9k\u00e9nek be\u00e1ll\u00edt\u00e1sa", + "set_lock_usercode": "{entity_name} felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1nak be\u00e1ll\u00edt\u00e1sa", + "set_value": "Z-Wave \u00e9rt\u00e9k be\u00e1ll\u00edt\u00e1sa" + }, "condition_type": { "config_parameter": "Konfigur\u00e1lja a(z) {subtype} param\u00e9ter \u00e9rt\u00e9k\u00e9t", "node_status": "Csom\u00f3pont \u00e1llapota", @@ -63,7 +82,9 @@ "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" + "state.node_status": "A csom\u00f3pont \u00e1llapota megv\u00e1ltozott", + "zwave_js.value_updated.config_parameter": "\u00c9rt\u00e9kv\u00e1ltoz\u00e1s a {subtype}", + "zwave_js.value_updated.value": "\u00c9rt\u00e9kv\u00e1ltoz\u00e1s egy Z-Wave JS \u00e9rt\u00e9ken" } }, "options": { @@ -92,6 +113,10 @@ "emulate_hardware": "Hardver emul\u00e1ci\u00f3", "log_level": "Napl\u00f3szint", "network_key": "H\u00e1l\u00f3zati kulcs", + "s0_legacy_key": "S0 kulcs (r\u00e9gi)", + "s2_access_control_key": "S2 hozz\u00e1f\u00e9r\u00e9si ", + "s2_authenticated_key": "S2 hiteles\u00edtett kulcs", + "s2_unauthenticated_key": "S2 nem hiteles\u00edtett kulcs", "usb_path": "USB eszk\u00f6z \u00fatvonala" }, "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" @@ -109,7 +134,7 @@ "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" + "title": "V\u00e1lassza 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 61ea6762c7d..2004be7238f 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -8,7 +8,9 @@ "addon_start_failed": "Gagal memulai add-on Z-Wave JS.", "already_configured": "Perangkat sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", - "cannot_connect": "Gagal terhubung" + "cannot_connect": "Gagal terhubung", + "discovery_requires_supervisor": "Fitur penemuan membutuhkan supervisor.", + "not_zwave_device": "Perangkat yang ditemukan bukanperangkat Z-Wave." }, "error": { "addon_start_failed": "Gagal memulai add-on Z-Wave JS. Periksa konfigurasi.", @@ -16,6 +18,7 @@ "invalid_ws_url": "URL websocket tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, + "flow_title": "{name}", "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." @@ -51,6 +54,16 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Nilai parameter konfigurasi {subtype}", + "node_status": "Status node", + "value": "Nilai saat ini dari Nilai Z-Wave" + }, + "trigger_type": { + "state.node_status": "Status node berubah" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Gagal mendapatkan info penemuan add-on Z-Wave JS.", diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index 7c79cb304ef..af3416ed9a9 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -8,7 +8,9 @@ "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS.", "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", - "cannot_connect": "Impossibile connettersi" + "cannot_connect": "Impossibile connettersi", + "discovery_requires_supervisor": "Il rilevamento richiede il Supervisor.", + "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave." }, "error": { "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS. Controlla la configurazione.", @@ -16,6 +18,7 @@ "invalid_ws_url": "URL websocket non valido", "unknown": "Errore imprevisto" }, + "flow_title": "{name}", "progress": { "install_addon": "Attendi il termine dell'installazione del componente aggiuntivo Z-Wave JS. Questa operazione pu\u00f2 richiedere diversi minuti.", "start_addon": "Attendi il completamento dell'avvio del componente aggiuntivo Z-Wave JS. L'operazione potrebbe richiedere alcuni secondi." @@ -24,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Chiave di rete", + "s0_legacy_key": "Chiave S0 (Obsoleta)", + "s2_access_control_key": "Chiave di controllo di accesso S2", + "s2_authenticated_key": "Chiave S2 autenticata", + "s2_unauthenticated_key": "Chiave S2 non autenticata", "usb_path": "Percorso del dispositivo USB" }, "title": "Accedi alla configurazione del componente aggiuntivo Z-Wave JS" @@ -48,10 +55,22 @@ }, "start_addon": { "title": "Il componente aggiuntivo Z-Wave JS si sta avviando." + }, + "usb_confirm": { + "description": "Vuoi configurare {name} con il componente aggiuntivo JS Z-Wave?" } } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Cancella codice utente su {entity_name}", + "ping": "Dispositivo ping", + "refresh_value": "Aggiorna il/i valore/i per {entity_name}", + "reset_meter": "Azzerare i contatori su {subtype}", + "set_config_parameter": "Imposta il valore del parametro di configurazione {subtype}", + "set_lock_usercode": "Imposta un codice utente su {entity_name}", + "set_value": "Imposta un valore Z-Wave" + }, "condition_type": { "config_parameter": "Valore del parametro di configurazione {subtype}", "node_status": "Stato del nodo", @@ -63,7 +82,9 @@ "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" + "state.node_status": "Lo stato del nodo \u00e8 cambiato", + "zwave_js.value_updated.config_parameter": "Variazione sul parametro di configurazione {subtype}", + "zwave_js.value_updated.value": "Variazione su un valore Z-Wave JS" } }, "options": { @@ -92,6 +113,10 @@ "emulate_hardware": "Emulare l'hardware", "log_level": "Livello di registro", "network_key": "Chiave di rete", + "s0_legacy_key": "Chiave S0 (Obsoleta)", + "s2_access_control_key": "Chiave di controllo di accesso S2", + "s2_authenticated_key": "Chiave S2 autenticata", + "s2_unauthenticated_key": "Chiave S2 non autenticata", "usb_path": "Percorso del dispositivo USB" }, "title": "Entra nella configurazione 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 23d185f1ded..76718aa5346 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Netwerksleutel", + "s0_legacy_key": "S0 Sleutel (Legacy)", + "s2_access_control_key": "S2 Toegangscontrolesleutel", + "s2_authenticated_key": "S2 geverifieerde sleutel", + "s2_unauthenticated_key": "S2 niet-geverifieerde sleutel", "usb_path": "USB-apparaatpad" }, "title": "Voer de Z-Wave JS add-on configuratie in" @@ -53,11 +57,20 @@ "title": "De add-on Z-Wave JS wordt gestart." }, "usb_confirm": { - "description": "Wilt u {naam} instellen met de Z-Wave JS add-on?" + "description": "Wilt u {name} instellen met de Z-Wave JS add-on?" } } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Wis gebruikerscode van {entity_name}", + "ping": "Ping apparaat", + "refresh_value": "Ververs de waarde(s) voor {entity_name}", + "reset_meter": "Reset meters op {subtype}", + "set_config_parameter": "Stel waarde in voor configuratieparameter {subtype}", + "set_lock_usercode": "Stel gebruikerscode in voor {entity_name}", + "set_value": "Waarde van een Z-Wave waarde instellen" + }, "condition_type": { "config_parameter": "Config parameter {subtype} waarde", "node_status": "Knooppuntstatus", @@ -100,6 +113,10 @@ "emulate_hardware": "Emulate Hardware", "log_level": "Log level", "network_key": "Netwerksleutel", + "s0_legacy_key": "S0 Sleutel (Legacy)", + "s2_access_control_key": "S2 Toegangscontrolesleutel", + "s2_authenticated_key": "S2 geverifieerde sleutel", + "s2_unauthenticated_key": "S2 niet-geverifieerde sleutel", "usb_path": "USB-apparaatpad" }, "title": "Voer de configuratie van de Z-Wave JS-add-on in" diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index b69b1cb4f7a..f08f5bd07cb 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Nettverksn\u00f8kkel", + "s0_legacy_key": "S0-n\u00f8kkel (eldre)", + "s2_access_control_key": "N\u00f8kkel for S2-tilgangskontroll", + "s2_authenticated_key": "S2 Autentisert n\u00f8kkel", + "s2_unauthenticated_key": "S2 Uautentisert n\u00f8kkel", "usb_path": "USB enhetsbane" }, "title": "Angi konfigurasjon for Z-Wave JS-tillegg" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Fjern brukerkoden p\u00e5 {entity_name}", + "ping": "Ping -enhet", + "refresh_value": "Oppdater verdien (e) for {entity_name}", + "reset_meter": "Tilbakestill m\u00e5lere p\u00e5 {subtype}", + "set_config_parameter": "Angi verdien til konfigurasjonsparameteren {subtype}", + "set_lock_usercode": "Angi en brukerkode p\u00e5 {entity_name}", + "set_value": "Angi verdien for en Z-Wave-verdi" + }, "condition_type": { "config_parameter": "Konfigurer parameter {subtype} verdi", "node_status": "Nodestatus", @@ -100,6 +113,10 @@ "emulate_hardware": "Emuler maskinvare", "log_level": "Loggniv\u00e5", "network_key": "Nettverksn\u00f8kkel", + "s0_legacy_key": "S0-n\u00f8kkel (eldre)", + "s2_access_control_key": "N\u00f8kkel for S2-tilgangskontroll", + "s2_authenticated_key": "S2 Autentisert n\u00f8kkel", + "s2_unauthenticated_key": "S2 Uautentisert n\u00f8kkel", "usb_path": "USB enhetsbane" }, "title": "Angi konfigurasjon for Z-Wave JS-tillegg" diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index bd842cb1359..0ae905a0854 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -59,17 +59,17 @@ }, "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" + "config_parameter": "warto\u015b\u0107 parametru jest {subtype}", + "node_status": "stan w\u0119z\u0142a", + "value": "aktualna warto\u015b\u0107 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", + "event.notification.entry_control": "zostanie wys\u0142ane powiadomienie kontroli wpisu", + "event.notification.notification": "zostanie wys\u0142ane powiadomienie", + "event.value_notification.basic": "wyst\u0105pi podstawowe wydarzenie CC na {subtype}", + "event.value_notification.central_scene": "wyst\u0105pi akcja sceny centralnej na {subtype}", + "event.value_notification.scene_activation": "zostanie aktywowana scena na {subtype}", + "state.node_status": "zmieni si\u0119 stan w\u0119z\u0142a", "zwave_js.value_updated.config_parameter": "zmieni si\u0119 warto\u015b\u0107 parametru konfiguracji {subtype}", "zwave_js.value_updated.value": "zmieni si\u0119 warto\u015b\u0107 na Z-Wave JS" } diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 994bfb54cfc..9ae79edb32d 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438", + "s0_legacy_key": "\u041a\u043b\u044e\u0447 S0 (\u0443\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0439)", + "s2_access_control_key": "\u041a\u043b\u044e\u0447 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 S2", + "s2_authenticated_key": "\u041a\u043b\u044e\u0447 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", + "s2_unauthenticated_key": "\u041a\u043b\u044e\u0447 \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "\u041e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430 {entity_name}", + "ping": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u0441\u0432\u044f\u0437\u044c \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c", + "refresh_value": "\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0434\u043b\u044f {entity_name}", + "reset_meter": "\u0421\u0431\u0440\u043e\u0441\u0438\u0442\u044c \u0441\u0447\u0435\u0442\u0447\u0438\u043a\u0438 \u043d\u0430 {subtype}", + "set_config_parameter": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0437\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}", + "set_lock_usercode": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043a\u043e\u0434 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430 {entity_name}", + "set_value": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 Z-Wave Value" + }, "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", @@ -100,6 +113,10 @@ "emulate_hardware": "\u042d\u043c\u0443\u043b\u044f\u0446\u0438\u044f \u043e\u0431\u043e\u0440\u0443\u0434\u043e\u0432\u0430\u043d\u0438\u044f", "log_level": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c \u0436\u0443\u0440\u043d\u0430\u043b\u0430", "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438", + "s0_legacy_key": "\u041a\u043b\u044e\u0447 S0 (\u0443\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0439)", + "s2_access_control_key": "\u041a\u043b\u044e\u0447 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 S2", + "s2_authenticated_key": "\u041a\u043b\u044e\u0447 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", + "s2_unauthenticated_key": "\u041a\u043b\u044e\u0447 \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \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 e9038ed9a00..7b495ed0ca0 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "\u7db2\u8def\u5bc6\u9470", + "s0_legacy_key": "S0 \u5bc6\u9470\uff08\u820a\u7248\uff09", + "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u5bc6\u9470", + "s2_authenticated_key": "S2 \u9a57\u8b49\u5bc6\u9470", + "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u5bc6\u9470", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u8a2d\u5b9a" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "\u6e05\u9664 {entity_name} usercode", + "ping": "Ping \u88dd\u7f6e", + "refresh_value": "\u66f4\u65b0 {entity_name} \u6578\u503c", + "reset_meter": "\u91cd\u7f6e {subtype} \u8a08\u91cf", + "set_config_parameter": "\u8a2d\u5b9a {subtype} \u8a2d\u5b9a\u8b8a\u6578", + "set_lock_usercode": "\u8a2d\u5b9a {entity_name} usercode", + "set_value": "\u8a2d\u5b9a Z-Wave \u6578\u503c" + }, "condition_type": { "config_parameter": "\u8a2d\u5b9a\u53c3\u6578 {subtype} \u6578\u503c", "node_status": "\u7bc0\u9ede\u72c0\u614b", @@ -100,6 +113,10 @@ "emulate_hardware": "\u6a21\u64ec\u786c\u9ad4", "log_level": "\u65e5\u8a8c\u8a18\u9304\u7b49\u7d1a", "network_key": "\u7db2\u8def\u5bc6\u9470", + "s0_legacy_key": "S0 \u5bc6\u9470\uff08\u820a\u7248\uff09", + "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u5bc6\u9470", + "s2_authenticated_key": "S2 \u9a57\u8b49\u5bc6\u9470", + "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u5bc6\u9470", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u8a2d\u5b9a" diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index 69e770e3817..ca9bd7d24a2 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -2,10 +2,14 @@ from __future__ import annotations from types import ModuleType -from typing import Any, Callable, cast +from typing import cast +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.const import CONF_PLATFORM -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType from .triggers import value_updated @@ -40,14 +44,14 @@ async def async_validate_trigger_config( async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, - action: Callable, - automation_info: dict[str, Any], -) -> Callable: + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) assert hasattr(platform, "async_attach_trigger") return cast( - Callable, + CALLBACK_TYPE, await getattr(platform, "async_attach_trigger")( hass, config, action, automation_info ), diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index a2dbb84cf3b..fdf2589073e 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -3,7 +3,6 @@ from __future__ import annotations import functools import logging -from typing import Any, Callable import voluptuous as vol from zwave_js_server.const import CommandClass @@ -11,6 +10,10 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from zwave_js_server.model.value import Value, get_value_id +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.zwave_js.const import ( ATTR_COMMAND_CLASS, ATTR_COMMAND_CLASS_NAME, @@ -79,8 +82,8 @@ TRIGGER_SCHEMA = vol.All( async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, - action: Callable, - automation_info: dict[str, Any], + action: AutomationActionType, + automation_info: AutomationTriggerInfo, *, platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: @@ -110,9 +113,7 @@ async def async_attach_trigger( unsubs = [] job = HassJob(action) - trigger_data: dict = {} - if automation_info: - trigger_data = automation_info.get("trigger_data", {}) + trigger_data = automation_info["trigger_data"] @callback def async_on_value_updated( diff --git a/homeassistant/config.py b/homeassistant/config.py index 754420dbcce..540336eeca3 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,14 +2,14 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Sequence +from collections.abc import Callable, Sequence import logging import os from pathlib import Path import re import shutil from types import ModuleType -from typing import Any, Callable +from typing import Any from urllib.parse import urlparse from awesomeversion import AwesomeVersion @@ -512,9 +512,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non # Only load auth during startup. if not hasattr(hass, "auth"): - auth_conf = config.get(CONF_AUTH_PROVIDERS) - - if auth_conf is None: + if (auth_conf := config.get(CONF_AUTH_PROVIDERS)) is None: auth_conf = [{"type": "homeassistant"}] mfa_conf = config.get( @@ -598,9 +596,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non cust_glob = OrderedDict(config[CONF_CUSTOMIZE_GLOB]) for name, pkg in config[CONF_PACKAGES].items(): - pkg_cust = pkg.get(CONF_CORE) - - if pkg_cust is None: + if (pkg_cust := pkg.get(CONF_CORE)) is None: continue try: @@ -957,9 +953,7 @@ def async_notify_setup_error( # pylint: disable=import-outside-toplevel from homeassistant.components import persistent_notification - errors = hass.data.get(DATA_PERSISTENT_ERRORS) - - if errors is None: + if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: errors = hass.data[DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or display_link diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 50d279ec8b0..03d7df740ba 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -492,8 +492,7 @@ class ConfigEntry: Returns True if config entry is up-to-date or has been migrated. """ - handler = HANDLERS.get(self.domain) - if handler is None: + if (handler := HANDLERS.get(self.domain)) is None: _LOGGER.error( "Flow handler not found for entry %s for %s", self.title, self.domain ) @@ -716,9 +715,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): ) raise data_entry_flow.UnknownHandler - handler = HANDLERS.get(handler_key) - - if handler is None: + if (handler := HANDLERS.get(handler_key)) is None: raise data_entry_flow.UnknownHandler if not context or "source" not in context: @@ -769,6 +766,7 @@ class ConfigEntries: self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries: dict[str, ConfigEntry] = {} + self._domain_index: dict[str, list[str]] = {} self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) EntityRegistryDisabledHandler(hass).async_setup() @@ -796,7 +794,9 @@ class ConfigEntries: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return [entry for entry in self._entries.values() if entry.domain == domain] + return [ + self._entries[entry_id] for entry_id in self._domain_index.get(domain, []) + ] async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" @@ -805,14 +805,13 @@ class ConfigEntries: f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry + self._domain_index.setdefault(entry.domain, []).append(entry.entry_id) await self.async_setup(entry.entry_id) self._async_schedule_save() async def async_remove(self, entry_id: str) -> dict[str, Any]: """Remove an entry.""" - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if not entry.state.recoverable: @@ -823,6 +822,9 @@ class ConfigEntries: await entry.async_remove(self.hass) del self._entries[entry.entry_id] + self._domain_index[entry.domain].remove(entry.entry_id) + if not self._domain_index[entry.domain]: + del self._domain_index[entry.domain] self._async_schedule_save() dev_reg, ent_reg = await asyncio.gather( @@ -881,9 +883,11 @@ class ConfigEntries: if config is None: self._entries = {} + self._domain_index = {} return entries = {} + domain_index: dict[str, list[str]] = {} for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -894,10 +898,13 @@ class ConfigEntries: "disable_new_entities" ) - entries[entry["entry_id"]] = ConfigEntry( + domain = entry["domain"] + entry_id = entry["entry_id"] + + entries[entry_id] = ConfigEntry( version=entry["version"], - domain=entry["domain"], - entry_id=entry["entry_id"], + domain=domain, + entry_id=entry_id, data=entry["data"], source=entry["source"], title=entry["title"], @@ -911,7 +918,9 @@ class ConfigEntries: pref_disable_new_entities=pref_disable_new_entities, pref_disable_polling=entry.get("pref_disable_polling"), ) + domain_index.setdefault(domain, []).append(entry_id) + self._domain_index = domain_index self._entries = entries async def async_setup(self, entry_id: str) -> bool: @@ -919,9 +928,7 @@ class ConfigEntries: Return True if entry has been successfully loaded. """ - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if entry.state is not ConfigEntryState.NOT_LOADED: @@ -943,9 +950,7 @@ class ConfigEntries: async def async_unload(self, entry_id: str) -> bool: """Unload a config entry.""" - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if not entry.state.recoverable: @@ -958,9 +963,7 @@ class ConfigEntries: If an entry was not loaded, will just load. """ - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry unload_result = await self.async_unload(entry_id) @@ -977,9 +980,7 @@ class ConfigEntries: If disabled_by is changed, the config entry will be reloaded. """ - entry = self.async_get_entry(entry_id) - - if entry is None: + if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if entry.disabled_by == disabled_by: @@ -1052,8 +1053,7 @@ class ConfigEntries: return False for listener_ref in entry.update_listeners: - listener = listener_ref() - if listener is not None: + if (listener := listener_ref()) is not None: self.hass.async_create_task(listener(self.hass, entry)) self._async_schedule_save() diff --git a/homeassistant/const.py b/homeassistant/const.py index dde2c8db2f5..707e7d87b01 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 = 9 -PATCH_VERSION: Final = "7" +MINOR_VERSION: Final = 10 +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) @@ -237,6 +237,7 @@ DEVICE_CLASS_BATTERY: Final = "battery" DEVICE_CLASS_CO: Final = "carbon_monoxide" DEVICE_CLASS_CO2: Final = "carbon_dioxide" DEVICE_CLASS_CURRENT: Final = "current" +DEVICE_CLASS_DATE: Final = "date" DEVICE_CLASS_ENERGY: Final = "energy" DEVICE_CLASS_HUMIDITY: Final = "humidity" DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" diff --git a/homeassistant/core.py b/homeassistant/core.py index 1b1849ba548..4221c435a55 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -488,6 +488,7 @@ class HomeAssistant: async def _await_and_log_pending(self, pending: Iterable[Awaitable[Any]]) -> None: """Await and log tasks that take a long time.""" + # pylint: disable=no-self-use wait_time = 0 while pending: _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) @@ -507,7 +508,7 @@ class HomeAssistant: """Stop Home Assistant and shuts down all threads. The "force" flag commands async_stop to proceed regardless of - Home Assistan't current state. You should not set this flag + Home Assistant's current state. You should not set this flag unless you're testing. This method is a coroutine. @@ -970,8 +971,7 @@ class State: if isinstance(last_updated, str): last_updated = dt_util.parse_datetime(last_updated) - context = json_dict.get("context") - if context: + if context := json_dict.get("context"): context = Context(id=context.get("id"), user_id=context.get("user_id")) return cls( @@ -1198,8 +1198,7 @@ class StateMachine: entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - old_state = self._states.get(entity_id) - if old_state is None: + if (old_state := self._states.get(entity_id)) is None: same_state = False same_attr = False last_changed = None @@ -1657,9 +1656,7 @@ class Config: def set_time_zone(self, time_zone_str: str) -> None: """Help to set the time zone.""" - time_zone = dt_util.get_time_zone(time_zone_str) - - if time_zone: + if time_zone := dt_util.get_time_zone(time_zone_str): self.time_zone = time_zone_str dt_util.set_default_time_zone(time_zone) else: @@ -1716,9 +1713,8 @@ class Config: store = self.hass.helpers.storage.Store( CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True ) - data = await store.async_load() - if not data: + if not (data := await store.async_load()): return # In 2021.9 we fixed validation to disallow a path (because that's never correct) @@ -1791,8 +1787,7 @@ def _async_create_timer(hass: HomeAssistant) -> None: ) # If we are more than a second late, a tick was missed - late = monotonic() - target - if late > 1: + if (late := monotonic() - target) > 1: hass.bus.async_fire( EVENT_TIMER_OUT_OF_SYNC, {ATTR_SECONDS: late}, diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 786cfe7e286..63d5566db40 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -93,9 +93,7 @@ class FlowManager(abc.ABC): async def async_wait_init_flow_finish(self, handler: str) -> None: """Wait till all flows in progress are initialized.""" - current = self._initializing.get(handler) - - if not current: + if not (current := self._initializing.get(handler)): return await asyncio.wait(current) @@ -189,9 +187,7 @@ class FlowManager(abc.ABC): self, flow_id: str, user_input: dict | None = None ) -> FlowResult: """Continue a configuration flow.""" - flow = self._progress.get(flow_id) - - if flow is None: + if (flow := self._progress.get(flow_id)) is None: raise UnknownFlow cur_step = flow.cur_step diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2eb4e43fe32..0bd6edaf146 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,11 +16,13 @@ FLOWS = [ "agent_dvr", "airly", "airnow", + "airthings", "airtouch4", "airvisual", "alarmdecoder", "almond", "ambee", + "amberelectric", "ambiclimate", "ambient_station", "apple_tv", @@ -52,6 +54,7 @@ FLOWS = [ "control4", "coolmaster", "coronavirus", + "crownstone", "daikin", "deconz", "denonavr", @@ -59,6 +62,7 @@ FLOWS = [ "dexcom", "dialogflow", "directv", + "dlna_dmr", "doorbird", "dsmr", "dunehd", @@ -167,6 +171,7 @@ FLOWS = [ "mill", "minecraft_server", "mobile_app", + "modem_callerid", "modern_forms", "monoprice", "motion_blinds", @@ -181,6 +186,7 @@ FLOWS = [ "neato", "nest", "netatmo", + "netgear", "nexia", "nfandroidtv", "nightscout", @@ -195,6 +201,7 @@ FLOWS = [ "ondilo_ico", "onewire", "onvif", + "opengarage", "opentherm_gw", "openuv", "openweathermap", @@ -264,6 +271,8 @@ FLOWS = [ "srp_energy", "starline", "subaru", + "surepetcare", + "switchbot", "switcher_kis", "syncthing", "syncthru", @@ -272,7 +281,6 @@ FLOWS = [ "tado", "tasmota", "tellduslive", - "tesla", "tibber", "tile", "toon", @@ -299,8 +307,10 @@ FLOWS = [ "vizio", "volumio", "wallbox", + "watttime", "waze_travel_time", "wemo", + "whirlpool", "wiffi", "wilight", "withings", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index cf442504121..6dcb3251e4e 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -259,26 +259,31 @@ DHCP = [ "domain": "tado", "hostname": "tado*" }, - { - "domain": "tesla", - "hostname": "tesla_*", - "macaddress": "4CFCAA*" - }, - { - "domain": "tesla", - "hostname": "tesla_*", - "macaddress": "044EAF*" - }, - { - "domain": "tesla", - "hostname": "tesla_*", - "macaddress": "98ED5C*" - }, { "domain": "toon", "hostname": "eneco-*", "macaddress": "74C63B*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "403F8C*" + }, + { + "domain": "tplink", + "hostname": "ep*", + "macaddress": "E848B8*" + }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "E848B8*" + }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "909A4A*" + }, { "domain": "tplink", "hostname": "hs*", @@ -304,6 +309,21 @@ DHCP = [ "hostname": "hs*", "macaddress": "B09575*" }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "C006C3*" + }, + { + "domain": "tplink", + "hostname": "ep*", + "macaddress": "003192*" + }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "003192*" + }, { "domain": "tplink", "hostname": "k[lp]*", @@ -329,6 +349,11 @@ DHCP = [ "hostname": "k[lp]*", "macaddress": "B09575*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "C006C3*" + }, { "domain": "tplink", "hostname": "lb*", @@ -354,30 +379,6 @@ DHCP = [ "hostname": "lb*", "macaddress": "B09575*" }, - { - "domain": "tuya", - "macaddress": "508A06*" - }, - { - "domain": "tuya", - "macaddress": "7CF666*" - }, - { - "domain": "tuya", - "macaddress": "10D561*" - }, - { - "domain": "tuya", - "macaddress": "D4A651*" - }, - { - "domain": "tuya", - "macaddress": "68572D*" - }, - { - "domain": "tuya", - "macaddress": "1869D8*" - }, { "domain": "verisure", "macaddress": "0023C1*" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 1638d932e89..e5e823b404a 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -151,6 +151,12 @@ SSDP = { "manufacturer": "konnected.io" } ], + "netgear": [ + { + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "manufacturer": "NETGEAR, Inc." + } + ], "roku": [ { "deviceType": "urn:roku-com:device:player:1-0", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 844c09fea40..bebeb393329 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -6,12 +6,29 @@ To update, run python3 -m script.hassfest # fmt: off USB = [ + { + "domain": "modem_callerid", + "vid": "0572", + "pid": "1340" + }, { "domain": "zha", "vid": "10C4", "pid": "EA60", "description": "*2652*" }, + { + "domain": "zha", + "vid": "10C4", + "pid": "EA60", + "description": "*tubeszb*" + }, + { + "domain": "zha", + "vid": "1A86", + "pid": "7523", + "description": "*tubeszb*" + }, { "domain": "zha", "vid": "1CF1", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index fd5194bd025..da7c08df675 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -131,6 +131,11 @@ ZEROCONF = { "name": "shelly*" } ], + "_hue._tcp.local.": [ + { + "domain": "hue" + } + ], "_ipp._tcp.local.": [ { "domain": "ipp" @@ -163,6 +168,10 @@ ZEROCONF = { }, { "domain": "xiaomi_miio" + }, + { + "domain": "yeelight", + "name": "yeelink-*" } ], "_nanoleafapi._tcp.local.": [ diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index a0642e8ead2..93383f49b1e 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -18,9 +18,7 @@ def config_per_platform(config: ConfigType, domain: str) -> Iterable[tuple[Any, Async friendly. """ for config_key in extract_domain_configs(config, domain): - platform_config = config[config_key] - - if not platform_config: + if not (platform_config := config[config_key]): continue if not isinstance(platform_config, list): diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 696f2d40cb8..c7f77bf086d 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from contextlib import suppress from ssl import SSLContext import sys from types import MappingProxyType -from typing import Any, Callable, cast +from typing import Any, cast import aiohttp from aiohttp import web diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 26e063ae1f2..83505fc8356 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from homeassistant.requirements import ( RequirementsNotFound, + async_clear_install_history, async_get_integration_with_requirements, ) import homeassistant.util.yaml.loader as yaml_loader @@ -71,6 +72,7 @@ async def async_check_ha_config_file( # noqa: C901 This method is a coroutine. """ result = HomeAssistantConfig() + async_clear_install_history(hass) def _pack_error( package: str, component: str, config: ConfigType, message: str @@ -125,8 +127,12 @@ async def async_check_ha_config_file( # noqa: C901 for domain in components: try: integration = await async_get_integration_with_requirements(hass, domain) - except (RequirementsNotFound, loader.IntegrationNotFound) as ex: - result.add_error(f"Component error: {domain} - {ex}") + except loader.IntegrationNotFound as ex: + if not hass.config.safe_mode: + result.add_error(f"Integration error: {domain} - {ex}") + continue + except RequirementsNotFound as ex: + result.add_error(f"Integration error: {domain} - {ex}") continue try: @@ -210,8 +216,11 @@ async def async_check_ha_config_file( # noqa: C901 hass, p_name ) platform = p_integration.get_platform(domain) + except loader.IntegrationNotFound as ex: + if not hass.config.safe_mode: + result.add_error(f"Platform error {domain}.{p_name} - {ex}") + continue except ( - loader.IntegrationNotFound, RequirementsNotFound, ImportError, ) as ex: diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 8704932db73..014c1f4f272 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -9,11 +9,11 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging import secrets import time -from typing import Any, Callable, Dict, cast +from typing import Any, Dict, cast from aiohttp import client, web import async_timeout @@ -406,6 +406,7 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Receive authorization code.""" + # pylint: disable=no-self-use if "code" not in request.query or "state" not in request.query: return web.Response( text=f"Missing code or state parameter in {request.url}" @@ -505,7 +506,7 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str: if secret is None: secret = hass.data[DATA_JWT_SECRET] = secrets.token_hex() - return jwt.encode(data, secret, algorithm="HS256").decode() + return jwt.encode(data, secret, algorithm="HS256") @callback diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3a2fb6c70e4..4bfcb98e9d4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,7 +1,7 @@ """Helpers for config validation using voluptuous.""" from __future__ import annotations -from collections.abc import Hashable +from collections.abc import Callable, Hashable from datetime import ( date as date_sys, datetime as datetime_sys, @@ -15,7 +15,7 @@ from numbers import Number import os import re from socket import _GLOBAL_DEFAULT_TIMEOUT # type: ignore # private, not in typeshed -from typing import Any, Callable, Dict, TypeVar, cast +from typing import Any, Dict, TypeVar, cast from urllib.parse import urlparse from uuid import UUID diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 07f12a08262..7cdb1823ae0 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -1,6 +1,7 @@ """Helpers for the data entry flow.""" from __future__ import annotations +from http import HTTPStatus from typing import Any from aiohttp import web @@ -9,7 +10,6 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_NOT_FOUND import homeassistant.helpers.config_validation as cv @@ -77,9 +77,11 @@ class FlowManagerIndexView(_BaseFlowManagerView): }, ) except data_entry_flow.UnknownHandler: - return self.json_message("Invalid handler specified", HTTP_NOT_FOUND) + return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) except data_entry_flow.UnknownStep: - return self.json_message("Handler does not support user", HTTP_BAD_REQUEST) + return self.json_message( + "Handler does not support user", HTTPStatus.BAD_REQUEST + ) result = self._prepare_result_json(result) @@ -94,7 +96,7 @@ class FlowManagerResourceView(_BaseFlowManagerView): try: result = await self._flow_mgr.async_configure(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) result = self._prepare_result_json(result) @@ -108,9 +110,9 @@ class FlowManagerResourceView(_BaseFlowManagerView): try: result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) except vol.Invalid: - return self.json_message("User input malformed", HTTP_BAD_REQUEST) + return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) result = self._prepare_result_json(result) @@ -121,6 +123,6 @@ class FlowManagerResourceView(_BaseFlowManagerView): try: self._flow_mgr.async_abort(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) + return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) return self.json_message("Flow aborted") diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 75e0215d2cb..e3f13e3ad16 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from logging import Logger -from typing import Any, Callable +from typing import Any from homeassistant.core import HassJob, HomeAssistant, callback diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index e20748913ba..4bf57c1a4e1 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -1,10 +1,11 @@ """Deprecation helpers for Home Assistant.""" from __future__ import annotations +from collections.abc import Callable import functools import inspect import logging -from typing import Any, Callable +from typing import Any from ..helpers.frame import MissingIntegrationFrame, get_integration_frame diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index b22b1740a4f..f3051e9dfd7 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -332,6 +332,7 @@ class DeviceRegistry: new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, + add_config_entry_id: str | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, disabled_by: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, @@ -347,6 +348,7 @@ class DeviceRegistry: new_identifiers=new_identifiers, sw_version=sw_version, via_device_id=via_device_id, + add_config_entry_id=add_config_entry_id, remove_config_entry_id=remove_config_entry_id, disabled_by=disabled_by, suggested_area=suggested_area, diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 53dbca867d7..3f7523e9299 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -7,7 +7,8 @@ There are two different types of discoveries that can be fired/listened for. """ from __future__ import annotations -from typing import Any, Callable, TypedDict +from collections.abc import Callable +from typing import Any, TypedDict from homeassistant import core, setup from homeassistant.core import CALLBACK_TYPE diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 2b365412e27..d1f7b2b97f9 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -1,6 +1,9 @@ """Helpers for Home Assistant dispatcher & internal component/platform.""" +from __future__ import annotations + +from collections.abc import Callable import logging -from typing import Any, Callable +from typing import Any from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.loader import bind_hass diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 847dc062764..f04949c0caa 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -99,13 +99,11 @@ def get_capability(hass: HomeAssistant, entity_id: str, capability: str) -> Any First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(capability) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.capabilities.get(capability) if entry.capabilities else None @@ -116,13 +114,11 @@ def get_device_class(hass: HomeAssistant, entity_id: str) -> str | None: First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(ATTR_DEVICE_CLASS) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.device_class @@ -133,13 +129,11 @@ def get_supported_features(hass: HomeAssistant, entity_id: str) -> int: First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.supported_features or 0 @@ -150,13 +144,11 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: First try the statemachine, then entity registry. """ - state = hass.states.get(entity_id) - if state: + if state := hass.states.get(entity_id): return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) entity_registry = er.async_get(hass) - entry = entity_registry.async_get(entity_id) - if not entry: + if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") return entry.unit_of_measurement @@ -467,8 +459,7 @@ class Entity(ABC): """Convert state to string.""" if not self.available: return STATE_UNAVAILABLE - state = self.state - if state is None: + if (state := self.state) is None: return STATE_UNKNOWN if isinstance(state, float): # If the entity's state is a float, limit precision according to machine @@ -511,28 +502,22 @@ class Entity(ABC): entry = self.registry_entry # pylint: disable=consider-using-ternary - name = (entry and entry.name) or self.name - if name is not None: + if (name := (entry and entry.name) or self.name) is not None: attr[ATTR_FRIENDLY_NAME] = name - icon = (entry and entry.icon) or self.icon - if icon is not None: + if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - entity_picture = self.entity_picture - if entity_picture is not None: + if (entity_picture := self.entity_picture) is not None: attr[ATTR_ENTITY_PICTURE] = entity_picture - assumed_state = self.assumed_state - if assumed_state: + if assumed_state := self.assumed_state: attr[ATTR_ASSUMED_STATE] = assumed_state - supported_features = self.supported_features - if supported_features is not None: + if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features - device_class = self.device_class - if device_class is not None: + if (device_class := self.device_class) is not None: attr[ATTR_DEVICE_CLASS] = str(device_class) end = timer() @@ -636,8 +621,7 @@ class Entity(ABC): finished, _ = await asyncio.wait([task], timeout=SLOW_UPDATE_WARNING) for done in finished: - exc = done.exception() - if exc: + if exc := done.exception(): raise exc return @@ -750,7 +734,10 @@ class Entity(ABC): Not to be extended by integrations. """ if self.platform: - info = {"domain": self.platform.platform_name} + info = { + "domain": self.platform.platform_name, + "custom_component": "custom_components" in type(self).__module__, + } if self.platform.config_entry: info["source"] = SOURCE_CONFIG_ENTRY diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 7f329d02133..d65f485166b 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import timedelta from itertools import chain import logging from types import ModuleType -from typing import Any, Callable +from typing import Any import voluptuous as vol diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 778b7f3747d..c212645325c 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -2,13 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Iterable +from collections.abc import Callable, Coroutine, Iterable from contextvars import ContextVar from datetime import datetime, timedelta import logging from logging import Logger from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Protocol +from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 69415030a87..88233b30df7 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,9 +10,9 @@ timer. from __future__ import annotations from collections import OrderedDict -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping import logging -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast import attr @@ -381,6 +381,7 @@ class EntityRegistry: *, name: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + config_entry_id: str | None | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -391,6 +392,7 @@ class EntityRegistry: entity_id, name=name, icon=icon, + config_entry_id=config_entry_id, area_id=area_id, new_entity_id=new_entity_id, new_unique_id=new_unique_id, diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index e026955f286..727231dde00 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,9 +1,9 @@ """Helper class to implement include/exclude of entities and domains.""" from __future__ import annotations +from collections.abc import Callable import fnmatch import re -from typing import Callable import voluptuous as vol diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5d5f71d2fd5..b37a79a83ec 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -175,16 +175,14 @@ def async_track_state_change( def state_change_filter(event: Event) -> bool: """Handle specific state changes.""" if from_state is not None: - old_state = event.data.get("old_state") - if old_state is not None: + if (old_state := event.data.get("old_state")) is not None: old_state = old_state.state if not match_from_state(old_state): return False if to_state is not None: - new_state = event.data.get("new_state") - if new_state is not None: + if (new_state := event.data.get("new_state")) is not None: new_state = new_state.state if not match_to_state(new_state): @@ -246,8 +244,7 @@ def async_track_state_change_event( care about the state change events so we can do a fast dict lookup to route events. """ - entity_ids = _async_string_to_lower_list(entity_ids) - if not entity_ids: + if not (entity_ids := _async_string_to_lower_list(entity_ids)): return _remove_empty_listener entity_callbacks = hass.data.setdefault(TRACK_STATE_CHANGE_CALLBACKS, {}) @@ -336,8 +333,7 @@ def async_track_entity_registry_updated_event( Similar to async_track_state_change_event. """ - entity_ids = _async_string_to_lower_list(entity_ids) - if not entity_ids: + if not (entity_ids := _async_string_to_lower_list(entity_ids)): return _remove_empty_listener entity_callbacks = hass.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) @@ -419,8 +415,7 @@ def async_track_state_added_domain( action: Callable[[Event], Any], ) -> Callable[[], None]: """Track state change events when an entity is added to domains.""" - domains = _async_string_to_lower_list(domains) - if not domains: + if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener domain_callbacks = hass.data.setdefault(TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {}) @@ -472,8 +467,7 @@ def async_track_state_removed_domain( action: Callable[[Event], Any], ) -> Callable[[], None]: """Track state change events when an entity is removed from domains.""" - domains = _async_string_to_lower_list(domains) - if not domains: + if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener domain_callbacks = hass.data.setdefault(TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {}) @@ -1185,8 +1179,7 @@ def async_track_point_in_utc_time( # as measured by utcnow(). That is bad when callbacks have assumptions # about the current time. Thus, we rearm the timer for the remaining # time. - delta = (utc_point_in_time - now).total_seconds() - if delta > 0: + if (delta := (utc_point_in_time - now).total_seconds()) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) cancel_callback = hass.loop.call_later(delta, run_action, job) @@ -1520,11 +1513,9 @@ def _rate_limit_for_event( event: Event, info: RenderInfo, track_template_: TrackTemplate ) -> timedelta | None: """Determine the rate limit for an event.""" - entity_id = event.data.get(ATTR_ENTITY_ID) - # Specifically referenced entities are excluded # from the rate limit - if entity_id in info.entities: + if event.data.get(ATTR_ENTITY_ID) in info.entities: return None if track_template_.rate_limit is not None: diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index f10e8f4c25c..7d29d78dc54 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import functools import logging from traceback import FrameSummary, extract_stack -from typing import Any, Callable, TypeVar, cast +from typing import Any, TypeVar, cast from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index cc4f5be47d8..ec5b0a5e7ca 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -1,8 +1,9 @@ """Helper for httpx.""" from __future__ import annotations +from collections.abc import Callable import sys -from typing import Any, Callable +from typing import Any import httpx diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 57a81083c50..0e619fe551b 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable import logging -from typing import Any, Callable +from typing import Any from homeassistant.core import Event, HomeAssistant from homeassistant.loader import async_get_integration, bind_hass diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index cfc89240b78..c25cc25e99b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,10 +1,10 @@ """Module to coordinate user intentions.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable import logging import re -from typing import Any, Callable, Dict +from typing import Any, Dict import voluptuous as vol diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 389b3f4d2d5..350f50423e1 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Hashable +from collections.abc import Callable, Hashable from datetime import datetime, timedelta import logging -from typing import Any, Callable +from typing import Any from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index e5e8ef4fd52..c43b918c59d 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,14 +2,14 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence +from collections.abc import Callable, Sequence from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta from functools import partial import itertools import logging from types import MappingProxyType -from typing import Any, Callable, Dict, TypedDict, Union, cast +from typing import Any, Dict, TypedDict, Union, cast import async_timeout import voluptuous as vol diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 5bde59c06dc..6258d6db1c3 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1,7 +1,8 @@ """Selectors for Home Assistant.""" from __future__ import annotations -from typing import Any, Callable, cast +from collections.abc import Callable +from typing import Any, cast import voluptuous as vol diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index ed07c6bda63..002d6447441 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Iterable +from collections.abc import Awaitable, Callable, Iterable import dataclasses from functools import partial, wraps import logging -from typing import TYPE_CHECKING, Any, Callable, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict import voluptuous as vol diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index e7e827ec5c3..805ac193834 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -1,6 +1,7 @@ """Helpers to help during startup.""" -from collections.abc import Awaitable -from typing import Callable +from __future__ import annotations + +from collections.abc import Awaitable, Callable from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import Event, HomeAssistant, callback diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 5700a7f854b..116c9186149 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -2,16 +2,17 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress from json import JSONEncoder import logging import os -from typing import Any, Callable +from typing import Any from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback from homeassistant.helpers.event import async_call_later -from homeassistant.loader import bind_hass +from homeassistant.loader import MAX_LOAD_CONCURRENTLY, bind_hass from homeassistant.util import json as json_util # mypy: allow-untyped-calls, allow-untyped-defs, no-warn-return-any @@ -20,6 +21,8 @@ from homeassistant.util import json as json_util STORAGE_DIR = ".storage" _LOGGER = logging.getLogger(__name__) +STORAGE_SEMAPHORE = "storage_semaphore" + @bind_hass async def async_migrator( @@ -109,8 +112,12 @@ class Store: async def _async_load(self): """Load the data and ensure the task is removed.""" + if STORAGE_SEMAPHORE not in self.hass.data: + self.hass.data[STORAGE_SEMAPHORE] = asyncio.Semaphore(MAX_LOAD_CONCURRENTLY) + try: - return await self._async_load_data() + async with self.hass.data[STORAGE_SEMAPHORE]: + return await self._async_load_data() finally: self._load_task = None diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ade580694c8..019b3aaf5fb 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,8 +5,8 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Generator, Iterable -from contextlib import suppress +from collections.abc import Callable, Generator, Iterable +from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta from functools import partial, wraps @@ -17,7 +17,7 @@ from operator import attrgetter import random import re import sys -from typing import Any, Callable, cast +from typing import Any, cast from urllib.parse import urlencode as urllib_urlencode import weakref @@ -88,7 +88,9 @@ _COLLECTABLE_STATE_ATTRIBUTES = { ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) -template_cv: ContextVar[str | None] = ContextVar("template_cv", default=None) +template_cv: ContextVar[tuple[str, str] | None] = ContextVar( + "template_cv", default=None +) @bind_hass @@ -336,13 +338,14 @@ class Template: def ensure_valid(self) -> None: """Return if template is valid.""" - if self.is_static or self._compiled_code is not None: - return + with set_template(self.template, "compiling"): + if self.is_static or self._compiled_code is not None: + return - try: - self._compiled_code = self._env.compile(self.template) # type: ignore[no-untyped-call] - except jinja2.TemplateError as err: - raise TemplateError(err) from err + try: + self._compiled_code = self._env.compile(self.template) # type: ignore[no-untyped-call] + except jinja2.TemplateError as err: + raise TemplateError(err) from err def render( self, @@ -914,15 +917,23 @@ def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: 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 +def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: + """Get a device ID from an entity ID or device name.""" entity_reg = entity_registry.async_get(hass) - entity = entity_reg.async_get(entity_id) - if entity is None: - return None - return entity.device_id + entity = entity_reg.async_get(entity_id_or_device_name) + if entity is not None: + return entity.device_id + + dev_reg = device_registry.async_get(hass) + return next( + ( + id + for id, device in dev_reg.devices.items() + if (name := device.name_by_user or device.name) + and (str(entity_id_or_device_name) == name) + ), + None, + ) def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: @@ -1193,8 +1204,26 @@ def utcnow(hass: HomeAssistant) -> datetime: return dt_util.utcnow() -def forgiving_round(value, precision=0, method="common"): - """Round accepted strings.""" +def warn_no_default(function, value, default): + """Log warning if no default is specified.""" + template, action = template_cv.get() or ("", "rendering or compiling") + _LOGGER.warning( + ( + "Template warning: '%s' got invalid input '%s' when %s template '%s' " + "but no default was specified. Currently '%s' will return '%s', however this template will fail " + "to render in Home Assistant core 2021.12" + ), + function, + value, + action, + template, + function, + default, + ) + + +def forgiving_round(value, precision=0, method="common", default=_SENTINEL): + """Filter to round a value.""" try: # support rounding methods like jinja multiplier = float(10 ** precision) @@ -1210,94 +1239,137 @@ def forgiving_round(value, precision=0, method="common"): return int(value) if precision == 0 else value except (ValueError, TypeError): # If value can't be converted to float - return value + if default is _SENTINEL: + warn_no_default("round", value, value) + return value + return default -def multiply(value, amount): +def multiply(value, amount, default=_SENTINEL): """Filter to convert value to float and multiply it.""" try: return float(value) * amount except (ValueError, TypeError): # If value can't be converted to float - return value + if default is _SENTINEL: + warn_no_default("multiply", value, value) + return value + return default -def logarithm(value, base=math.e): - """Filter to get logarithm of the value with a specific base.""" +def logarithm(value, base=math.e, default=_SENTINEL): + """Filter and function to get logarithm of the value with a specific base.""" try: return math.log(float(value), float(base)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("log", value, value) + return value + return default -def sine(value): - """Filter to get sine of the value.""" +def sine(value, default=_SENTINEL): + """Filter and function to get sine of the value.""" try: return math.sin(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("sin", value, value) + return value + return default -def cosine(value): - """Filter to get cosine of the value.""" +def cosine(value, default=_SENTINEL): + """Filter and function to get cosine of the value.""" try: return math.cos(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("cos", value, value) + return value + return default -def tangent(value): - """Filter to get tangent of the value.""" +def tangent(value, default=_SENTINEL): + """Filter and function to get tangent of the value.""" try: return math.tan(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("tan", value, value) + return value + return default -def arc_sine(value): - """Filter to get arc sine of the value.""" +def arc_sine(value, default=_SENTINEL): + """Filter and function to get arc sine of the value.""" try: return math.asin(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("asin", value, value) + return value + return default -def arc_cosine(value): - """Filter to get arc cosine of the value.""" +def arc_cosine(value, default=_SENTINEL): + """Filter and function to get arc cosine of the value.""" try: return math.acos(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("acos", value, value) + return value + return default -def arc_tangent(value): - """Filter to get arc tangent of the value.""" +def arc_tangent(value, default=_SENTINEL): + """Filter and function to get arc tangent of the value.""" try: return math.atan(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("atan", value, value) + return value + return default -def arc_tangent2(*args): - """Filter to calculate four quadrant arc tangent of y / x.""" +def arc_tangent2(*args, default=_SENTINEL): + """Filter and function to calculate four quadrant arc tangent of y / x. + + The parameters to atan2 may be passed either in an iterable or as separate arguments + The default value may be passed either as a positional or in a keyword argument + """ try: - if len(args) == 1 and isinstance(args[0], (list, tuple)): + if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)): + if len(args) == 2 and default is _SENTINEL: + # Default value passed as a positional argument + default = args[1] args = args[0] + elif len(args) == 3 and default is _SENTINEL: + # Default value passed as a positional argument + default = args[2] return math.atan2(float(args[0]), float(args[1])) except (ValueError, TypeError): - return args + if default is _SENTINEL: + warn_no_default("atan2", args, args) + return args + return default -def square_root(value): - """Filter to get square root of the value.""" +def square_root(value, default=_SENTINEL): + """Filter and function to get square root of the value.""" try: return math.sqrt(float(value)) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("sqrt", value, value) + return value + return default -def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): +def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL): """Filter to convert given timestamp to format.""" try: date = dt_util.utc_from_timestamp(value) @@ -1308,10 +1380,13 @@ def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): return date.strftime(date_format) except (ValueError, TypeError): # If timestamp can't be converted - return value + if default is _SENTINEL: + warn_no_default("timestamp_custom", value, value) + return value + return default -def timestamp_local(value): +def timestamp_local(value, default=_SENTINEL): """Filter to convert given timestamp to local date/time.""" try: return dt_util.as_local(dt_util.utc_from_timestamp(value)).strftime( @@ -1319,32 +1394,44 @@ def timestamp_local(value): ) except (ValueError, TypeError): # If timestamp can't be converted - return value + if default is _SENTINEL: + warn_no_default("timestamp_local", value, value) + return value + return default -def timestamp_utc(value): +def timestamp_utc(value, default=_SENTINEL): """Filter to convert given timestamp to UTC date/time.""" try: return dt_util.utc_from_timestamp(value).strftime(DATE_STR_FORMAT) except (ValueError, TypeError): # If timestamp can't be converted - return value + if default is _SENTINEL: + warn_no_default("timestamp_utc", value, value) + return value + return default -def forgiving_as_timestamp(value): - """Try to convert value to timestamp.""" +def forgiving_as_timestamp(value, default=_SENTINEL): + """Filter and function which tries to convert value to timestamp.""" try: return dt_util.as_timestamp(value) except (ValueError, TypeError): - return None + if default is _SENTINEL: + warn_no_default("as_timestamp", value, None) + return None + return default -def strptime(string, fmt): +def strptime(string, fmt, default=_SENTINEL): """Parse a time string to datetime.""" try: return datetime.strptime(string, fmt) except (ValueError, AttributeError, TypeError): - return string + if default is _SENTINEL: + warn_no_default("strptime", string, string) + return string + return default def fail_when_undefined(value): @@ -1354,12 +1441,37 @@ def fail_when_undefined(value): return value -def forgiving_float(value): +def forgiving_float(value, default=_SENTINEL): """Try to convert value to a float.""" try: return float(value) except (ValueError, TypeError): - return value + if default is _SENTINEL: + warn_no_default("float", value, value) + return value + return default + + +def forgiving_float_filter(value, default=_SENTINEL): + """Try to convert value to a float.""" + try: + return float(value) + except (ValueError, TypeError): + if default is _SENTINEL: + warn_no_default("float", value, 0) + return 0 + return default + + +def is_number(value): + """Try to convert value to a float.""" + try: + fvalue = float(value) + except (ValueError, TypeError): + return False + if math.isnan(fvalue) or math.isinf(fvalue): + return False + return True def regex_match(value, find="", ignorecase=False): @@ -1389,10 +1501,15 @@ def regex_search(value, find="", ignorecase=False): def regex_findall_index(value, find="", index=0, ignorecase=False): """Find all matches using regex and then pick specific match index.""" + return regex_findall(value, find, ignorecase)[index] + + +def regex_findall(value, find="", ignorecase=False): + """Find all matches using regex.""" if not isinstance(value, str): value = str(value) flags = re.I if ignorecase else 0 - return re.findall(find, value, flags)[index] + return re.findall(find, value, flags) def bitwise_and(first_value, second_value): @@ -1469,22 +1586,33 @@ def urlencode(value): return urllib_urlencode(value).encode("utf-8") +@contextmanager +def set_template(template_str: str, action: str) -> Generator: + """Store template being parsed or rendered in a Contextvar to aid error handling.""" + template_cv.set((template_str, action)) + try: + yield + finally: + template_cv.set(None) + + def _render_with_context( template_str: str, template: jinja2.Template, **kwargs: Any ) -> str: """Store template being rendered in a ContextVar to aid error handling.""" - template_cv.set(template_str) - return template.render(**kwargs) + with set_template(template_str, "rendering"): + return template.render(**kwargs) class LoggingUndefined(jinja2.Undefined): """Log on undefined variables.""" def _log_message(self): - template = template_cv.get() or "" + template, action = template_cv.get() or ("", "rendering or compiling") _LOGGER.warning( - "Template variable warning: %s when rendering '%s'", + "Template variable warning: %s when %s '%s'", self._undefined_message, + action, template, ) @@ -1492,10 +1620,11 @@ class LoggingUndefined(jinja2.Undefined): try: return super()._fail_with_undefined_error(*args, **kwargs) except self._undefined_exception as ex: - template = template_cv.get() or "" + template, action = template_cv.get() or ("", "rendering or compiling") _LOGGER.error( - "Template variable error: %s when rendering '%s'", + "Template variable error: %s when %s '%s'", self._undefined_message, + action, template, ) raise ex @@ -1557,10 +1686,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["regex_match"] = regex_match self.filters["regex_replace"] = regex_replace self.filters["regex_search"] = regex_search + self.filters["regex_findall"] = regex_findall self.filters["regex_findall_index"] = regex_findall_index self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or self.filters["ord"] = ord + self.filters["is_number"] = is_number + self.filters["float"] = forgiving_float_filter self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -1583,6 +1715,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["urlencode"] = urlencode self.globals["max"] = max self.globals["min"] = min + self.globals["is_number"] = is_number self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 58b0dc19d43..0c364124c50 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -2,11 +2,11 @@ from __future__ import annotations from collections import deque -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any, Callable, cast +from typing import Any, cast from homeassistant.helpers.typing import TemplateVarsType import homeassistant.util.dt as dt_util diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 29f344a6fa0..c7ef6d31be4 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -2,16 +2,16 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging -from types import MappingProxyType -from typing import Any, Callable +from typing import Any import voluptuous as vol from homeassistant.const import CONF_ID, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.loader import IntegrationNotFound, async_get_integration _PLATFORM_ALIASES = { @@ -62,15 +62,9 @@ async def async_initialize_triggers( name: str, log_cb: Callable, home_assistant_start: bool = False, - variables: dict[str, Any] | MappingProxyType | None = None, + variables: TemplateVarsType = None, ) -> CALLBACK_TYPE | None: """Initialize triggers.""" - info = { - "domain": domain, - "name": name, - "home_assistant_start": home_assistant_start, - "variables": variables, - } triggers = [] for idx, conf in enumerate(trigger_config): @@ -78,7 +72,13 @@ async def async_initialize_triggers( trigger_id = conf.get(CONF_ID, f"{idx}") trigger_idx = f"{idx}" trigger_data = {"id": trigger_id, "idx": trigger_idx} - info = {**info, "trigger_data": trigger_data} + info = { + "domain": domain, + "name": name, + "home_assistant_start": home_assistant_start, + "variables": variables, + "trigger_data": trigger_data, + } triggers.append(platform.async_attach_trigger(hass, conf, action, info)) attach_results = await asyncio.gather(*triggers, return_exceptions=True) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 2203ab240ef..a48fca8a01f 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta import logging from time import monotonic -from typing import Callable, Generic, TypeVar +from typing import Generic, TypeVar import urllib.error import aiohttp diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e186c5d24ba..fbbd4ad3ae6 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -322,6 +322,14 @@ class Integration: return integration _LOGGER.warning(CUSTOM_WARNING, integration.domain) + if integration.version is None: + _LOGGER.error( + "The custom integration '%s' does not have a " + "version key in the manifest file and was blocked from loading. " + "See https://developers.home-assistant.io/blog/2021/01/29/custom-integration-changes#versions for more details", + integration.domain, + ) + return None try: AwesomeVersion( integration.version, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb6d10e4084..c263e0f4f3c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,22 +1,21 @@ -PyJWT==1.7.1 +PyJWT==2.1.0 PyNaCl==1.4.0 -aiodiscover==1.4.2 +aiodiscover==1.4.4 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.20.0 +async-upnp-client==0.22.5 async_timeout==3.0.1 attrs==21.2.0 -awesomeversion==21.4.0 +awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 -certifi>=2020.12.5 -ciso8601==2.1.3 -cryptography==3.3.2 -defusedxml==0.7.1 -emoji==1.2.0 -hass-nabucasa==0.46.0 -home-assistant-frontend==20210830.0 +certifi>=2021.5.30 +ciso8601==2.2.0 +cryptography==3.4.8 +emoji==1.5.0 +hass-nabucasa==0.50.0 +home-assistant-frontend==20211006.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 @@ -27,14 +26,13 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.22.0 pyyaml==5.4.1 -requests==2.25.1 -ruamel.yaml==0.15.100 +requests==2.26.0 scapy==2.4.5 sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 -voluptuous==0.12.1 +voluptuous==0.12.2 yarl==1.6.3 -zeroconf==0.36.2 +zeroconf==0.36.7 pycryptodome>=3.6.6 @@ -77,3 +75,7 @@ pandas==1.3.0 # https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error # This is fixed in 2021.8.28 regex==2021.8.28 + +# anyio has a bug that was fixed in 3.3.1 +# can remove after httpx/httpcore updates its anyio version pin +anyio>=3.3.1 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 67d0ede96bc..aad4a6b1f46 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import Iterable +import logging import os from typing import Any, cast -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.loader import Integration, IntegrationNotFound, async_get_integration @@ -15,9 +16,11 @@ 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 +MAX_INSTALL_FAILURES = 3 DATA_PIP_LOCK = "pip_lock" DATA_PKG_CACHE = "pkg_cache" DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" +DATA_INSTALL_FAILURE_HISTORY = "install_failure_history" CONSTRAINT_FILE = "package_constraints.txt" DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = { "dhcp": ("dhcp",), @@ -25,6 +28,7 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = { "ssdp": ("ssdp",), "zeroconf": ("zeroconf", "homekit"), } +_LOGGER = logging.getLogger(__name__) class RequirementsNotFound(HomeAssistantError): @@ -135,6 +139,13 @@ async def _async_process_integration( raise result +@callback +def async_clear_install_history(hass: HomeAssistant) -> None: + """Forget the install history.""" + if install_failure_history := hass.data.get(DATA_INSTALL_FAILURE_HISTORY): + install_failure_history.clear() + + async def async_process_requirements( hass: HomeAssistant, name: str, requirements: list[str] ) -> None: @@ -146,22 +157,47 @@ async def async_process_requirements( pip_lock = hass.data.get(DATA_PIP_LOCK) if pip_lock is None: pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() + install_failure_history = hass.data.get(DATA_INSTALL_FAILURE_HISTORY) + if install_failure_history is None: + install_failure_history = hass.data[DATA_INSTALL_FAILURE_HISTORY] = set() kwargs = pip_kwargs(hass.config.config_dir) async with pip_lock: for req in requirements: - if pkg_util.is_installed(req): - continue + await _async_process_requirements( + hass, name, req, install_failure_history, kwargs + ) - def _install(req: str, kwargs: dict[str, Any]) -> bool: - """Install requirement.""" - return pkg_util.install_package(req, **kwargs) - ret = await hass.async_add_executor_job(_install, req, kwargs) +async def _async_process_requirements( + hass: HomeAssistant, + name: str, + req: str, + install_failure_history: set[str], + kwargs: Any, +) -> None: + """Install a requirement and save failures.""" + if req in install_failure_history: + _LOGGER.info( + "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", + req, + ) + raise RequirementsNotFound(name, [req]) - if not ret: - raise RequirementsNotFound(name, [req]) + if pkg_util.is_installed(req): + return + + def _install(req: str, kwargs: dict[str, Any]) -> bool: + """Install requirement.""" + return pkg_util.install_package(req, **kwargs) + + for _ in range(MAX_INSTALL_FAILURES): + if await hass.async_add_executor_job(_install, req, kwargs): + return + + install_failure_history.add(req) + raise RequirementsNotFound(name, [req]) def pip_kwargs(config_dir: str | None) -> dict[str, Any]: diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 5eae0b1b2da..3fd4118a25b 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -26,6 +26,9 @@ from homeassistant.util.thread import deadlock_safe_shutdown # use case. # MAX_EXECUTOR_WORKERS = 64 +TASK_CANCELATION_TIMEOUT = 5 + +_LOGGER = logging.getLogger(__name__) @dataclasses.dataclass @@ -105,4 +108,69 @@ async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: def run(runtime_config: RuntimeConfig) -> int: """Run Home Assistant.""" asyncio.set_event_loop_policy(HassEventLoopPolicy(runtime_config.debug)) - return asyncio.run(setup_and_run_hass(runtime_config)) + # Backport of cpython 3.9 asyncio.run with a _cancel_all_tasks that times out + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(setup_and_run_hass(runtime_config)) + finally: + try: + _cancel_all_tasks_with_timeout(loop, TASK_CANCELATION_TIMEOUT) + loop.run_until_complete(loop.shutdown_asyncgens()) + # Once cpython 3.8 is no longer supported we can use the + # the built-in loop.shutdown_default_executor + loop.run_until_complete(_shutdown_default_executor(loop)) + finally: + asyncio.set_event_loop(None) + loop.close() + + +def _cancel_all_tasks_with_timeout( + loop: asyncio.AbstractEventLoop, timeout: int +) -> None: + """Adapted _cancel_all_tasks from python 3.9 with a timeout.""" + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete(asyncio.wait(to_cancel, timeout=timeout)) + + for task in to_cancel: + if task.cancelled(): + continue + if not task.done(): + _LOGGER.warning( + "Task could not be canceled and was still running after shutdown: %s", + task, + ) + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during shutdown", + "exception": task.exception(), + "task": task, + } + ) + + +async def _shutdown_default_executor(loop: asyncio.AbstractEventLoop) -> None: + """Backport of cpython 3.9 schedule the shutdown of the default executor.""" + future = loop.create_future() + + def _do_shutdown() -> None: + try: + loop._default_executor.shutdown(wait=True) # type: ignore # pylint: disable=protected-access + loop.call_soon_threadsafe(future.set_result, None) + except Exception as ex: # pylint: disable=broad-except + loop.call_soon_threadsafe(future.set_exception, ex) + + thread = threading.Thread(target=_do_shutdown) + thread.start() + try: + await future + finally: + thread.join() diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 2acefbce128..1005b48e1ca 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -4,12 +4,13 @@ from __future__ import annotations import argparse import asyncio import collections +from collections.abc import Callable from contextlib import suppress from datetime import datetime import json import logging from timeit import default_timer as timer -from typing import Callable, TypeVar +from typing import TypeVar from homeassistant import core from homeassistant.components.websocket_api.const import JSON_DUMP diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0ff339169a7..8e683bb5a1b 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -4,11 +4,11 @@ from __future__ import annotations import argparse import asyncio from collections import OrderedDict -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from glob import glob import logging import os -from typing import Any, Callable +from typing import Any from unittest.mock import patch from homeassistant import core @@ -21,7 +21,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==5.0.1",) +REQUIREMENTS = ("colorlog==6.4.1",) _LOGGER = logging.getLogger(__name__) # pylint: disable=protected-access diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 95bb29c4b9d..a917eb65b69 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -2,12 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Generator, Iterable +from collections.abc import Awaitable, Callable, Generator, Iterable import contextlib import logging.handlers from timeit import default_timer as timer from types import ModuleType -from typing import Callable from homeassistant import config as conf_util, core, loader, requirements from homeassistant.config import async_notify_setup_error @@ -366,9 +365,7 @@ async def async_process_deps_reqs( Module is a Python module of either a component or platform. """ - processed = hass.data.get(DATA_DEPS_REQS) - - if processed is None: + if (processed := hass.data.get(DATA_DEPS_REQS)) is None: processed = hass.data[DATA_DEPS_REQS] = set() elif integration.domain in processed: return diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 60f3e409f06..4f7f1af2e7d 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Iterable, KeysView +from collections.abc import Callable, Coroutine, Iterable, KeysView from datetime import datetime, timedelta import enum from functools import lru_cache, wraps @@ -12,7 +12,7 @@ import socket import string import threading from types import MappingProxyType -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar import slugify as unicode_slug diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 86308b48f7a..bf7250b68e6 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -3,13 +3,13 @@ from __future__ import annotations from asyncio import Semaphore, coroutines, ensure_future, gather, get_running_loop from asyncio.events import AbstractEventLoop -from collections.abc import Awaitable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine import concurrent.futures import functools import logging import threading from traceback import extract_stack -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index c81beddb07a..ebd3b175905 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -3,11 +3,21 @@ from __future__ import annotations import colorsys import math +from typing import NamedTuple import attr # mypy: disallow-any-generics + +class RGBColor(NamedTuple): + """RGB hex values.""" + + r: int + g: int + b: int + + # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against @@ -15,156 +25,156 @@ import attr # This lets "dark seagreen" and "dark sea green" both match the same # color "darkseagreen". COLORS = { - "aliceblue": (240, 248, 255), - "antiquewhite": (250, 235, 215), - "aqua": (0, 255, 255), - "aquamarine": (127, 255, 212), - "azure": (240, 255, 255), - "beige": (245, 245, 220), - "bisque": (255, 228, 196), - "black": (0, 0, 0), - "blanchedalmond": (255, 235, 205), - "blue": (0, 0, 255), - "blueviolet": (138, 43, 226), - "brown": (165, 42, 42), - "burlywood": (222, 184, 135), - "cadetblue": (95, 158, 160), - "chartreuse": (127, 255, 0), - "chocolate": (210, 105, 30), - "coral": (255, 127, 80), - "cornflowerblue": (100, 149, 237), - "cornsilk": (255, 248, 220), - "crimson": (220, 20, 60), - "cyan": (0, 255, 255), - "darkblue": (0, 0, 139), - "darkcyan": (0, 139, 139), - "darkgoldenrod": (184, 134, 11), - "darkgray": (169, 169, 169), - "darkgreen": (0, 100, 0), - "darkgrey": (169, 169, 169), - "darkkhaki": (189, 183, 107), - "darkmagenta": (139, 0, 139), - "darkolivegreen": (85, 107, 47), - "darkorange": (255, 140, 0), - "darkorchid": (153, 50, 204), - "darkred": (139, 0, 0), - "darksalmon": (233, 150, 122), - "darkseagreen": (143, 188, 143), - "darkslateblue": (72, 61, 139), - "darkslategray": (47, 79, 79), - "darkslategrey": (47, 79, 79), - "darkturquoise": (0, 206, 209), - "darkviolet": (148, 0, 211), - "deeppink": (255, 20, 147), - "deepskyblue": (0, 191, 255), - "dimgray": (105, 105, 105), - "dimgrey": (105, 105, 105), - "dodgerblue": (30, 144, 255), - "firebrick": (178, 34, 34), - "floralwhite": (255, 250, 240), - "forestgreen": (34, 139, 34), - "fuchsia": (255, 0, 255), - "gainsboro": (220, 220, 220), - "ghostwhite": (248, 248, 255), - "gold": (255, 215, 0), - "goldenrod": (218, 165, 32), - "gray": (128, 128, 128), - "green": (0, 128, 0), - "greenyellow": (173, 255, 47), - "grey": (128, 128, 128), - "honeydew": (240, 255, 240), - "hotpink": (255, 105, 180), - "indianred": (205, 92, 92), - "indigo": (75, 0, 130), - "ivory": (255, 255, 240), - "khaki": (240, 230, 140), - "lavender": (230, 230, 250), - "lavenderblush": (255, 240, 245), - "lawngreen": (124, 252, 0), - "lemonchiffon": (255, 250, 205), - "lightblue": (173, 216, 230), - "lightcoral": (240, 128, 128), - "lightcyan": (224, 255, 255), - "lightgoldenrodyellow": (250, 250, 210), - "lightgray": (211, 211, 211), - "lightgreen": (144, 238, 144), - "lightgrey": (211, 211, 211), - "lightpink": (255, 182, 193), - "lightsalmon": (255, 160, 122), - "lightseagreen": (32, 178, 170), - "lightskyblue": (135, 206, 250), - "lightslategray": (119, 136, 153), - "lightslategrey": (119, 136, 153), - "lightsteelblue": (176, 196, 222), - "lightyellow": (255, 255, 224), - "lime": (0, 255, 0), - "limegreen": (50, 205, 50), - "linen": (250, 240, 230), - "magenta": (255, 0, 255), - "maroon": (128, 0, 0), - "mediumaquamarine": (102, 205, 170), - "mediumblue": (0, 0, 205), - "mediumorchid": (186, 85, 211), - "mediumpurple": (147, 112, 219), - "mediumseagreen": (60, 179, 113), - "mediumslateblue": (123, 104, 238), - "mediumspringgreen": (0, 250, 154), - "mediumturquoise": (72, 209, 204), - "mediumvioletred": (199, 21, 133), - "midnightblue": (25, 25, 112), - "mintcream": (245, 255, 250), - "mistyrose": (255, 228, 225), - "moccasin": (255, 228, 181), - "navajowhite": (255, 222, 173), - "navy": (0, 0, 128), - "navyblue": (0, 0, 128), - "oldlace": (253, 245, 230), - "olive": (128, 128, 0), - "olivedrab": (107, 142, 35), - "orange": (255, 165, 0), - "orangered": (255, 69, 0), - "orchid": (218, 112, 214), - "palegoldenrod": (238, 232, 170), - "palegreen": (152, 251, 152), - "paleturquoise": (175, 238, 238), - "palevioletred": (219, 112, 147), - "papayawhip": (255, 239, 213), - "peachpuff": (255, 218, 185), - "peru": (205, 133, 63), - "pink": (255, 192, 203), - "plum": (221, 160, 221), - "powderblue": (176, 224, 230), - "purple": (128, 0, 128), - "red": (255, 0, 0), - "rosybrown": (188, 143, 143), - "royalblue": (65, 105, 225), - "saddlebrown": (139, 69, 19), - "salmon": (250, 128, 114), - "sandybrown": (244, 164, 96), - "seagreen": (46, 139, 87), - "seashell": (255, 245, 238), - "sienna": (160, 82, 45), - "silver": (192, 192, 192), - "skyblue": (135, 206, 235), - "slateblue": (106, 90, 205), - "slategray": (112, 128, 144), - "slategrey": (112, 128, 144), - "snow": (255, 250, 250), - "springgreen": (0, 255, 127), - "steelblue": (70, 130, 180), - "tan": (210, 180, 140), - "teal": (0, 128, 128), - "thistle": (216, 191, 216), - "tomato": (255, 99, 71), - "turquoise": (64, 224, 208), - "violet": (238, 130, 238), - "wheat": (245, 222, 179), - "white": (255, 255, 255), - "whitesmoke": (245, 245, 245), - "yellow": (255, 255, 0), - "yellowgreen": (154, 205, 50), + "aliceblue": RGBColor(240, 248, 255), + "antiquewhite": RGBColor(250, 235, 215), + "aqua": RGBColor(0, 255, 255), + "aquamarine": RGBColor(127, 255, 212), + "azure": RGBColor(240, 255, 255), + "beige": RGBColor(245, 245, 220), + "bisque": RGBColor(255, 228, 196), + "black": RGBColor(0, 0, 0), + "blanchedalmond": RGBColor(255, 235, 205), + "blue": RGBColor(0, 0, 255), + "blueviolet": RGBColor(138, 43, 226), + "brown": RGBColor(165, 42, 42), + "burlywood": RGBColor(222, 184, 135), + "cadetblue": RGBColor(95, 158, 160), + "chartreuse": RGBColor(127, 255, 0), + "chocolate": RGBColor(210, 105, 30), + "coral": RGBColor(255, 127, 80), + "cornflowerblue": RGBColor(100, 149, 237), + "cornsilk": RGBColor(255, 248, 220), + "crimson": RGBColor(220, 20, 60), + "cyan": RGBColor(0, 255, 255), + "darkblue": RGBColor(0, 0, 139), + "darkcyan": RGBColor(0, 139, 139), + "darkgoldenrod": RGBColor(184, 134, 11), + "darkgray": RGBColor(169, 169, 169), + "darkgreen": RGBColor(0, 100, 0), + "darkgrey": RGBColor(169, 169, 169), + "darkkhaki": RGBColor(189, 183, 107), + "darkmagenta": RGBColor(139, 0, 139), + "darkolivegreen": RGBColor(85, 107, 47), + "darkorange": RGBColor(255, 140, 0), + "darkorchid": RGBColor(153, 50, 204), + "darkred": RGBColor(139, 0, 0), + "darksalmon": RGBColor(233, 150, 122), + "darkseagreen": RGBColor(143, 188, 143), + "darkslateblue": RGBColor(72, 61, 139), + "darkslategray": RGBColor(47, 79, 79), + "darkslategrey": RGBColor(47, 79, 79), + "darkturquoise": RGBColor(0, 206, 209), + "darkviolet": RGBColor(148, 0, 211), + "deeppink": RGBColor(255, 20, 147), + "deepskyblue": RGBColor(0, 191, 255), + "dimgray": RGBColor(105, 105, 105), + "dimgrey": RGBColor(105, 105, 105), + "dodgerblue": RGBColor(30, 144, 255), + "firebrick": RGBColor(178, 34, 34), + "floralwhite": RGBColor(255, 250, 240), + "forestgreen": RGBColor(34, 139, 34), + "fuchsia": RGBColor(255, 0, 255), + "gainsboro": RGBColor(220, 220, 220), + "ghostwhite": RGBColor(248, 248, 255), + "gold": RGBColor(255, 215, 0), + "goldenrod": RGBColor(218, 165, 32), + "gray": RGBColor(128, 128, 128), + "green": RGBColor(0, 128, 0), + "greenyellow": RGBColor(173, 255, 47), + "grey": RGBColor(128, 128, 128), + "honeydew": RGBColor(240, 255, 240), + "hotpink": RGBColor(255, 105, 180), + "indianred": RGBColor(205, 92, 92), + "indigo": RGBColor(75, 0, 130), + "ivory": RGBColor(255, 255, 240), + "khaki": RGBColor(240, 230, 140), + "lavender": RGBColor(230, 230, 250), + "lavenderblush": RGBColor(255, 240, 245), + "lawngreen": RGBColor(124, 252, 0), + "lemonchiffon": RGBColor(255, 250, 205), + "lightblue": RGBColor(173, 216, 230), + "lightcoral": RGBColor(240, 128, 128), + "lightcyan": RGBColor(224, 255, 255), + "lightgoldenrodyellow": RGBColor(250, 250, 210), + "lightgray": RGBColor(211, 211, 211), + "lightgreen": RGBColor(144, 238, 144), + "lightgrey": RGBColor(211, 211, 211), + "lightpink": RGBColor(255, 182, 193), + "lightsalmon": RGBColor(255, 160, 122), + "lightseagreen": RGBColor(32, 178, 170), + "lightskyblue": RGBColor(135, 206, 250), + "lightslategray": RGBColor(119, 136, 153), + "lightslategrey": RGBColor(119, 136, 153), + "lightsteelblue": RGBColor(176, 196, 222), + "lightyellow": RGBColor(255, 255, 224), + "lime": RGBColor(0, 255, 0), + "limegreen": RGBColor(50, 205, 50), + "linen": RGBColor(250, 240, 230), + "magenta": RGBColor(255, 0, 255), + "maroon": RGBColor(128, 0, 0), + "mediumaquamarine": RGBColor(102, 205, 170), + "mediumblue": RGBColor(0, 0, 205), + "mediumorchid": RGBColor(186, 85, 211), + "mediumpurple": RGBColor(147, 112, 219), + "mediumseagreen": RGBColor(60, 179, 113), + "mediumslateblue": RGBColor(123, 104, 238), + "mediumspringgreen": RGBColor(0, 250, 154), + "mediumturquoise": RGBColor(72, 209, 204), + "mediumvioletred": RGBColor(199, 21, 133), + "midnightblue": RGBColor(25, 25, 112), + "mintcream": RGBColor(245, 255, 250), + "mistyrose": RGBColor(255, 228, 225), + "moccasin": RGBColor(255, 228, 181), + "navajowhite": RGBColor(255, 222, 173), + "navy": RGBColor(0, 0, 128), + "navyblue": RGBColor(0, 0, 128), + "oldlace": RGBColor(253, 245, 230), + "olive": RGBColor(128, 128, 0), + "olivedrab": RGBColor(107, 142, 35), + "orange": RGBColor(255, 165, 0), + "orangered": RGBColor(255, 69, 0), + "orchid": RGBColor(218, 112, 214), + "palegoldenrod": RGBColor(238, 232, 170), + "palegreen": RGBColor(152, 251, 152), + "paleturquoise": RGBColor(175, 238, 238), + "palevioletred": RGBColor(219, 112, 147), + "papayawhip": RGBColor(255, 239, 213), + "peachpuff": RGBColor(255, 218, 185), + "peru": RGBColor(205, 133, 63), + "pink": RGBColor(255, 192, 203), + "plum": RGBColor(221, 160, 221), + "powderblue": RGBColor(176, 224, 230), + "purple": RGBColor(128, 0, 128), + "red": RGBColor(255, 0, 0), + "rosybrown": RGBColor(188, 143, 143), + "royalblue": RGBColor(65, 105, 225), + "saddlebrown": RGBColor(139, 69, 19), + "salmon": RGBColor(250, 128, 114), + "sandybrown": RGBColor(244, 164, 96), + "seagreen": RGBColor(46, 139, 87), + "seashell": RGBColor(255, 245, 238), + "sienna": RGBColor(160, 82, 45), + "silver": RGBColor(192, 192, 192), + "skyblue": RGBColor(135, 206, 235), + "slateblue": RGBColor(106, 90, 205), + "slategray": RGBColor(112, 128, 144), + "slategrey": RGBColor(112, 128, 144), + "snow": RGBColor(255, 250, 250), + "springgreen": RGBColor(0, 255, 127), + "steelblue": RGBColor(70, 130, 180), + "tan": RGBColor(210, 180, 140), + "teal": RGBColor(0, 128, 128), + "thistle": RGBColor(216, 191, 216), + "tomato": RGBColor(255, 99, 71), + "turquoise": RGBColor(64, 224, 208), + "violet": RGBColor(238, 130, 238), + "wheat": RGBColor(245, 222, 179), + "white": RGBColor(255, 255, 255), + "whitesmoke": RGBColor(245, 245, 245), + "yellow": RGBColor(255, 255, 0), + "yellowgreen": RGBColor(154, 205, 50), # And... - "homeassistant": (3, 169, 244), + "homeassistant": RGBColor(3, 169, 244), } @@ -186,7 +196,7 @@ class GamutType: blue: XYPoint = attr.ib() -def color_name_to_rgb(color_name: str) -> tuple[int, int, int]: +def color_name_to_rgb(color_name: str) -> RGBColor: """Convert color name to RGB hex value.""" # COLORS map has no spaces in it, so make the color_name have no # spaces in it as well for matching purposes diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py index d2943d39979..602cdba5598 100644 --- a/homeassistant/util/decorator.py +++ b/homeassistant/util/decorator.py @@ -1,6 +1,8 @@ """Decorator utility functions.""" -from collections.abc import Hashable -from typing import Callable, TypeVar +from __future__ import annotations + +from collections.abc import Callable, Hashable +from typing import TypeVar CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py index 6b21e9b4c47..38b9253ffbf 100644 --- a/homeassistant/util/distance.py +++ b/homeassistant/util/distance.py @@ -1,8 +1,8 @@ """Distance util functions.""" from __future__ import annotations +from collections.abc import Callable from numbers import Number -from typing import Callable from homeassistant.const import ( LENGTH, diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 93737ce0c3d..e2dd92a8b95 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -132,8 +132,7 @@ def parse_datetime(dt_str: str) -> dt.datetime | None: with suppress(ValueError, IndexError): return ciso8601.parse_datetime(dt_str) - match = DATETIME_RE.match(dt_str) - if not match: + if not (match := DATETIME_RE.match(dt_str)): return None kws: dict[str, Any] = match.groupdict() if kws["microsecond"]: @@ -269,16 +268,14 @@ def find_next_time_expression_time( Return None if no such value exists. """ - left = bisect.bisect_left(arr, cmp) - if left == len(arr): + if (left := bisect.bisect_left(arr, cmp)) == len(arr): return None return arr[left] result = now.replace(microsecond=0) # Match next second - next_second = _lower_bound(seconds, result.second) - if next_second is None: + if (next_second := _lower_bound(seconds, result.second)) is None: # No second to match in this minute. Roll-over to next minute. next_second = seconds[0] result += dt.timedelta(minutes=1) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index fac008d9f0f..e82bd968754 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -2,11 +2,12 @@ from __future__ import annotations from collections import deque +from collections.abc import Callable import json import logging import os import tempfile -from typing import Any, Callable +from typing import Any from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index 260c4f374fe..d5646b44c0f 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -43,8 +43,7 @@ def percentage_to_ordered_list_item(ordered_list: list[T], percentage: int) -> T 51-75: high 76-100: very_high """ - list_len = len(ordered_list) - if not list_len: + if not (list_len := len(ordered_list)): raise ValueError("The ordered list is empty") for offset, speed in enumerate(ordered_list): diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py deleted file mode 100644 index 8d813eaa5a4..00000000000 --- a/homeassistant/util/ruamel_yaml.py +++ /dev/null @@ -1,152 +0,0 @@ -"""ruamel.yaml utility functions.""" -from __future__ import annotations - -from collections import OrderedDict -from contextlib import suppress -import logging -import os -from os import O_CREAT, O_TRUNC, O_WRONLY, stat_result -from typing import Union - -import ruamel.yaml -from ruamel.yaml import YAML -from ruamel.yaml.compat import StringIO -from ruamel.yaml.constructor import SafeConstructor -from ruamel.yaml.error import YAMLError - -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.yaml import secret_yaml - -_LOGGER = logging.getLogger(__name__) - -JSON_TYPE = Union[list, dict, str] # pylint: disable=invalid-name - - -class ExtSafeConstructor(SafeConstructor): - """Extended SafeConstructor.""" - - name: str | None = None - - -class UnsupportedYamlError(HomeAssistantError): - """Unsupported YAML.""" - - -class WriteError(HomeAssistantError): - """Error writing the data.""" - - -def _include_yaml( - constructor: ExtSafeConstructor, node: ruamel.yaml.nodes.Node -) -> JSON_TYPE: - """Load another YAML file and embeds it using the !include tag. - - Example: - device_tracker: !include device_tracker.yaml - - """ - if constructor.name is None: - raise HomeAssistantError( - 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) - - -def _yaml_unsupported( - constructor: ExtSafeConstructor, node: ruamel.yaml.nodes.Node -) -> None: - raise UnsupportedYamlError( - f"Unsupported YAML, you can not use {node.tag} in " - f"{os.path.basename(constructor.name or '(None)')}" - ) - - -def object_to_yaml(data: JSON_TYPE) -> str: - """Create yaml string from object.""" - yaml = YAML(typ="rt") - yaml.indent(sequence=4, offset=2) - stream = StringIO() - try: - yaml.dump(data, stream) - result: str = stream.getvalue() - return result - except YAMLError as exc: - _LOGGER.error("YAML error: %s", exc) - raise HomeAssistantError(exc) from exc - - -def yaml_to_object(data: str) -> JSON_TYPE: - """Create object from yaml string.""" - yaml = YAML(typ="rt") - try: - result: list | dict | str = yaml.load(data) - return result - except YAMLError as exc: - _LOGGER.error("YAML error: %s", exc) - raise HomeAssistantError(exc) from exc - - -def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE: - """Load a YAML file.""" - if round_trip: - yaml = YAML(typ="rt") - yaml.preserve_quotes = True # type: ignore[assignment] - else: - if ExtSafeConstructor.name is None: - ExtSafeConstructor.name = fname - yaml = YAML(typ="safe") - yaml.Constructor = ExtSafeConstructor - - try: - with open(fname, encoding="utf-8") as conf_file: - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return yaml.load(conf_file) or OrderedDict() - except YAMLError as exc: - _LOGGER.error("YAML error in %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - except UnicodeDecodeError as exc: - _LOGGER.error("Unable to read file %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - - -def save_yaml(fname: str, data: JSON_TYPE) -> None: - """Save a YAML file.""" - yaml = YAML(typ="rt") - yaml.indent(sequence=4, offset=2) - tmp_fname = f"{fname}__TEMP__" - try: - try: - file_stat = os.stat(fname) - except OSError: - file_stat = stat_result((0o644, -1, -1, -1, -1, -1, -1, -1, -1, -1)) - with open( - os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC, file_stat.st_mode), - "w", - encoding="utf-8", - ) as temp_file: - yaml.dump(data, temp_file) - os.replace(tmp_fname, fname) - if hasattr(os, "chown") and file_stat.st_ctime > -1: - with suppress(OSError): - os.chown(fname, file_stat.st_uid, file_stat.st_gid) - except YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) from exc - except OSError as exc: - _LOGGER.exception("Saving YAML file %s failed: %s", fname, exc) - raise WriteError(exc) from exc - finally: - if os.path.exists(tmp_fname): - try: - os.remove(tmp_fname) - except OSError as exc: - # If we are cleaning up then something else went wrong, so - # we should suppress likely follow-on errors in the cleanup - _LOGGER.error("YAML replacement cleanup failed: %s", exc) - - -ExtSafeConstructor.add_constructor("!secret", secret_yaml) -ExtSafeConstructor.add_constructor("!include", _include_yaml) -ExtSafeConstructor.add_constructor(None, _yaml_unsupported) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 58edac6d280..e6ac5fd364a 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -60,9 +60,7 @@ class Secrets: def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]: """Load the secrets yaml from path.""" - secret_path = secret_dir / SECRET_YAML - - if secret_path in self._cache: + if (secret_path := secret_dir / SECRET_YAML) in self._cache: return self._cache[secret_path] _LOGGER.debug("Loading %s", secret_path) diff --git a/mypy.ini b/mypy.ini index 02a2800a801..a9c4ed4e3d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -308,6 +308,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.crownstone.*] +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.device_automation.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -341,6 +352,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.dlna_dmr.*] +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 @@ -605,6 +627,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.iqvia.*] +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.knx.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -693,6 +726,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.modbus.*] +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.mysensors.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -946,6 +990,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.samsungtv.*] +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 @@ -1056,6 +1111,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.surepetcare.*] +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.switch.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1111,6 +1177,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tautulli.*] +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 @@ -1133,6 +1210,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tplink.*] +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.tradfri.*] +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 @@ -1188,6 +1287,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vallox.*] +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.water_heater.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1285,9 +1395,6 @@ ignore_errors = true [mypy-homeassistant.components.bmw_connected_drive.*] ignore_errors = true -[mypy-homeassistant.components.cert_expiry.*] -ignore_errors = true - [mypy-homeassistant.components.climacell.*] ignore_errors = true @@ -1312,21 +1419,12 @@ ignore_errors = true [mypy-homeassistant.components.dhcp.*] ignore_errors = true -[mypy-homeassistant.components.directv.*] -ignore_errors = true - [mypy-homeassistant.components.doorbird.*] ignore_errors = true -[mypy-homeassistant.components.elkm1.*] -ignore_errors = true - [mypy-homeassistant.components.enphase_envoy.*] ignore_errors = true -[mypy-homeassistant.components.entur_public_transport.*] -ignore_errors = true - [mypy-homeassistant.components.evohome.*] ignore_errors = true @@ -1357,9 +1455,6 @@ ignore_errors = true [mypy-homeassistant.components.google_assistant.*] ignore_errors = true -[mypy-homeassistant.components.gpmdp.*] -ignore_errors = true - [mypy-homeassistant.components.gree.*] ignore_errors = true @@ -1495,6 +1590,9 @@ ignore_errors = true [mypy-homeassistant.components.nest.legacy.*] ignore_errors = true +[mypy-homeassistant.components.netgear.*] +ignore_errors = true + [mypy-homeassistant.components.nightscout.*] ignore_errors = true @@ -1597,9 +1695,6 @@ ignore_errors = true [mypy-homeassistant.components.somfy_mylink.*] ignore_errors = true -[mypy-homeassistant.components.sonarr.*] -ignore_errors = true - [mypy-homeassistant.components.sonos.*] ignore_errors = true @@ -1624,15 +1719,9 @@ ignore_errors = true [mypy-homeassistant.components.template.*] ignore_errors = true -[mypy-homeassistant.components.tesla.*] -ignore_errors = true - [mypy-homeassistant.components.toon.*] ignore_errors = true -[mypy-homeassistant.components.tplink.*] -ignore_errors = true - [mypy-homeassistant.components.unifi.*] ignore_errors = true diff --git a/pyproject.toml b/pyproject.toml index 8ca6a06868f..32c87227940 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ forced_separate = [ combine_as_imports = true [tool.pylint.MASTER] +py-version = "3.8" ignore = [ "tests", ] @@ -69,9 +70,11 @@ good-names = [ # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this +# consider-using-f-string - str.format sometimes more readable # --- # Enable once current issues are fixed: # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) +# consider-using-assignment-expr (Pylint CodeStyle extension) disable = [ "format", "abstract-class-little-used", @@ -94,7 +97,9 @@ disable = [ "too-many-boolean-expressions", "unused-argument", "wrong-import-order", + "consider-using-f-string", "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", ] enable = [ #"useless-suppression", # temporarily every now and then to clean them up @@ -120,9 +125,11 @@ overgeneral-exceptions = [ ] [tool.pylint.TYPING] -py-version = "3.8" runtime-typing = false +[tool.pylint.CODE_STYLE] +max-line-length-suggestions = 72 + [tool.pytest.ini_options] testpaths = [ "tests", diff --git a/requirements.txt b/requirements.txt index 70eeccdae1b..edb9253b7f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,20 +5,19 @@ aiohttp==3.7.4.post0 astral==2.2 async_timeout==3.0.1 attrs==21.2.0 -awesomeversion==21.4.0 +awesomeversion==21.8.1 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 -certifi>=2020.12.5 -ciso8601==2.1.3 +certifi>=2021.5.30 +ciso8601==2.2.0 httpx==0.19.0 jinja2==3.0.1 -PyJWT==1.7.1 -cryptography==3.3.2 +PyJWT==2.1.0 +cryptography==3.4.8 pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==5.4.1 -requests==2.25.1 -ruamel.yaml==0.15.100 -voluptuous==0.12.1 +requests==2.26.0 +voluptuous==0.12.2 voluptuous-serialize==2.4.0 yarl==1.6.3 diff --git a/requirements_all.txt b/requirements_all.txt index deafc44258e..92a334de096 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==4.1.0 +HAP-python==4.2.1 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -49,13 +49,13 @@ PyRMVtransport==0.3.2 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.8.0 +# PySwitchbot==0.11.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.5.2 +PyTurboJPEG==1.6.1 # homeassistant.components.vicare PyViCare==1.0.0 @@ -76,7 +76,7 @@ RtmAPI==0.7.2 TravisPy==0.3.5 # homeassistant.components.twitter -TwitterAPI==2.7.3 +TwitterAPI==2.7.5 # homeassistant.components.tof # VL53L1X2==0.1.5 @@ -85,7 +85,7 @@ TwitterAPI==2.7.3 WSDiscovery==2.0.0 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.12 +WazeRouteCalculator==0.13 # homeassistant.components.abode abodepy==1.2.0 @@ -148,7 +148,7 @@ aioazuredevops==1.3.5 aiobotocore==1.2.2 # homeassistant.components.dhcp -aiodiscover==1.4.2 +aiodiscover==1.4.4 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==8.0.0 +aioesphomeapi==9.1.5 # homeassistant.components.flo aioflo==0.4.1 @@ -182,14 +182,14 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.2 +aiohomekit==0.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.1 +aiohue==2.6.3 # homeassistant.components.imap aioimaplib==0.9.0 @@ -218,6 +218,9 @@ aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast aiomusiccast==0.9.2 +# homeassistant.components.nanoleaf +aionanoleaf==0.0.2 + # homeassistant.components.keyboard_remote aionotify==0.2.0 @@ -240,7 +243,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==0.6.4 +aioshelly==1.0.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -252,7 +255,10 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==26 +aiounifi==27 + +# homeassistant.components.watttime +aiowatttime==0.1.1 # homeassistant.components.yandex_transport aioymaps==1.1.0 @@ -260,6 +266,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings +airthings_cloud==0.0.1 + # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 @@ -272,11 +281,14 @@ alpha_vantage==2.3.1 # homeassistant.components.ambee ambee==0.3.0 +# homeassistant.components.amberelectric +amberelectric==1.0.3 + # homeassistant.components.ambiclimate ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.8.1 +amcrest==1.9.3 # homeassistant.components.androidtv androidtv[async]==0.0.60 @@ -294,7 +306,7 @@ apcaccess==0.0.13 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.4 +apprise==0.9.5.1 # homeassistant.components.aprs aprslib==0.6.46 @@ -318,7 +330,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.20.0 +async-upnp-client==0.22.5 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -356,9 +368,6 @@ baidu-aip==1.6.6 # homeassistant.components.homekit base36==0.1.1 -# homeassistant.components.modem_callerid -basicmodem==0.7 - # homeassistant.components.linux_battery batinfo==0.4.2 @@ -366,13 +375,13 @@ batinfo==0.4.2 # beacontools[scan]==1.2.3 # homeassistant.components.scrape -beautifulsoup4==4.9.3 +beautifulsoup4==4.10.0 # homeassistant.components.beewi_smartclim # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.27.0 +bellows==0.28.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.20 @@ -406,7 +415,7 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bond -bond-api==0.1.12 +bond-api==0.1.13 # homeassistant.components.bosch_shc boschshcpy==0.2.19 @@ -467,7 +476,7 @@ co2signal==0.4.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==5.0.1 +colorlog==6.4.1 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -489,6 +498,15 @@ coronavirus==1.1.1 # homeassistant.components.utility_meter croniter==1.0.6 +# homeassistant.components.crownstone +crownstone-cloud==1.4.8 + +# homeassistant.components.crownstone +crownstone-sse==2.0.2 + +# homeassistant.components.crownstone +crownstone-uart==2.1.0 + # homeassistant.components.datadog datadog==0.15.0 @@ -496,7 +514,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.1 +debugpy==1.4.3 # homeassistant.components.decora # decora==0.6 @@ -507,14 +525,13 @@ debugpy==1.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect -# homeassistant.components.ssdp defusedxml==0.7.1 # homeassistant.components.deluge deluge-client==1.7.1 # homeassistant.components.denonavr -denonavr==0.10.8 +denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 @@ -526,7 +543,7 @@ directv==0.4.0 discogs_client==2.3.0 # homeassistant.components.discord -discord.py==1.7.2 +discord.py==1.7.3 # homeassistant.components.digitalloggers dlipower==0.7.165 @@ -562,10 +579,10 @@ elgato==2.1.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==0.8.10 +elkm1-lib==1.0.0 # homeassistant.components.mobile_app -emoji==1.2.0 +emoji==1.5.0 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -583,7 +600,7 @@ env_canada==0.2.5 # envirophat==0.0.6 # homeassistant.components.enphase_envoy -envoy_reader==0.19.0 +envoy_reader==0.20.0 # homeassistant.components.season ephem==3.7.7.0 @@ -629,7 +646,7 @@ fitbit==0.3.1 fixerio==1.0.0a0 # homeassistant.components.fjaraskupan -fjaraskupan==1.0.0 +fjaraskupan==1.0.1 # homeassistant.components.flipr flipr-api==1.4.1 @@ -703,7 +720,7 @@ glances_api==0.2.0 gntp==1.0.3 # homeassistant.components.goalzero -goalzero==0.1.7 +goalzero==0.2.0 # homeassistant.components.google google-api-python-client==1.6.4 @@ -760,7 +777,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.46.0 +hass-nabucasa==0.50.0 # homeassistant.components.splunk hass_splunk==0.1.1 @@ -790,10 +807,10 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.2 +holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20210830.0 +home-assistant-frontend==20211006.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -865,7 +882,7 @@ influxdb-client==1.14.0 influxdb==5.2.3 # homeassistant.components.iotawatt -iotawattpy==0.0.8 +iotawattpy==0.1.0 # homeassistant.components.iperf3 iperf3==0.1.11 @@ -943,7 +960,7 @@ london-tube-status==0.2 luftdaten==0.6.5 # homeassistant.components.lupusec -lupupy==0.0.18 +lupupy==0.0.21 # homeassistant.components.lw12wifi lw12==0.9.2 @@ -988,7 +1005,7 @@ micloud==0.3 miflora==0.7.0 # homeassistant.components.mill -millheater==0.5.2 +millheater==0.6.0 # homeassistant.components.minio minio==4.0.9 @@ -997,7 +1014,7 @@ minio==4.0.9 mitemp_bt==0.0.3 # homeassistant.components.motion_blinds -motionblinds==0.4.10 +motionblinds==0.5.5 # homeassistant.components.motioneye motioneye-client==0.3.11 @@ -1030,7 +1047,7 @@ nessclient==0.9.15 netdata==0.2.0 # homeassistant.components.discovery -netdisco==2.9.0 +netdisco==3.0.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -1079,7 +1096,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.1 +numpy==1.21.2 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1169,6 +1186,9 @@ pencompy==0.0.3 # homeassistant.components.unifi_direct pexpect==4.6.0 +# homeassistant.components.modem_callerid +phone_modem==0.1.1 + # homeassistant.components.onewire pi1wire==0.1.0 @@ -1222,7 +1242,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.reddit -praw==7.2.0 +praw==7.4.0 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.5 @@ -1290,9 +1310,6 @@ pyCEC==0.5.1 # homeassistant.components.control4 pyControl4==0.0.6 -# homeassistant.components.tplink -pyHS100==0.3.5.2 - # homeassistant.components.met_eireann pyMetEireann==2021.8.0 @@ -1307,7 +1324,7 @@ pyRFXtrx==0.27.0 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.19.0 +pyTibber==0.19.1 # homeassistant.components.dlink pyW215==0.7.0 @@ -1343,7 +1360,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.3 +pyatmo==6.1.0 # homeassistant.components.atome pyatome==0.1.1 @@ -1364,7 +1381,7 @@ pyblackbird==0.5 pybotvac==0.0.22 # homeassistant.components.nissan_leaf -pycarwings2==2.11 +pycarwings2==2.12 # homeassistant.components.cloudflare pycfdns==1.2.1 @@ -1406,7 +1423,7 @@ pydaikin==2.4.4 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==83 +pydeconz==84 # homeassistant.components.delijn pydelijn==0.6.1 @@ -1432,6 +1449,9 @@ pyeconet==0.1.14 # homeassistant.components.edimax pyedimax==0.2.1 +# homeassistant.components.efergy +pyefergy==0.0.3 + # homeassistant.components.eight_sleep pyeight==0.1.9 @@ -1460,7 +1480,7 @@ pyfireservicerota==0.0.43 pyflic==2.0.3 # homeassistant.components.flume -pyflume==0.5.5 +pyflume==0.6.5 # homeassistant.components.flunearyou pyflunearyou==2.0.2 @@ -1566,7 +1586,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lastfm -pylast==4.2.0 +pylast==4.2.1 # homeassistant.components.launch_library pylaunches==1.0.0 @@ -1605,7 +1625,7 @@ pymazda==0.2.1 pymediaroom==0.6.4.1 # homeassistant.components.melcloud -pymelcloud==2.5.3 +pymelcloud==2.5.4 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 @@ -1634,14 +1654,11 @@ pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 -# homeassistant.components.nanoleaf -pynanoleaf==0.1.0 - # homeassistant.components.nello pynello==2.0.3 # homeassistant.components.netgear -pynetgear==0.6.1 +pynetgear==0.7.0 # homeassistant.components.netio pynetio==0.1.9.1 @@ -1653,7 +1670,7 @@ pynuki==1.4.1 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.3.0 +pynws==1.3.1 # homeassistant.components.nx584 pynx584==0.5 @@ -1706,7 +1723,7 @@ pypjlink2==1.2.1 pyplaato==0.0.15 # homeassistant.components.point -pypoint==2.1.0 +pypoint==2.2.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1761,6 +1778,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.crownstone # homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1772,7 +1790,7 @@ pysesame2==1.0.1 pysher==1.0.1 # homeassistant.components.sia -pysiaalarm==3.0.0 +pysiaalarm==3.0.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 @@ -1781,10 +1799,10 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.5 +pysma==0.6.6 # homeassistant.components.smappee -pysmappee==0.2.25 +pysmappee==0.2.27 # homeassistant.components.smartthings pysmartapp==0.3.3 @@ -1817,7 +1835,7 @@ pystiebeleltron==0.0.1.dev2 pysuez==0.1.19 # homeassistant.components.syncthru -pysyncthru==0.7.3 +pysyncthru==0.7.10 # homeassistant.components.tankerkoenig pytankerkoenig==0.0.6 @@ -1876,6 +1894,9 @@ python-join-api==0.0.6 # homeassistant.components.juicenet python-juicenet==1.0.2 +# homeassistant.components.tplink +python-kasa==0.4.0 + # homeassistant.components.lirc # python-lirc==1.2.3 @@ -1904,7 +1925,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.smarttub -python-smarttub==0.0.25 +python-smarttub==0.0.27 # homeassistant.components.sochain python-sochain-api==0.0.2 @@ -1913,7 +1934,7 @@ python-sochain-api==0.0.2 python-songpal==0.12 # homeassistant.components.tado -python-tado==0.10.0 +python-tado==0.12.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -1924,9 +1945,6 @@ python-telnet-vlc==2.0.1 # homeassistant.components.twitch python-twitch-client==0.6.0 -# homeassistant.components.velbus -python-velbus==2.1.2 - # homeassistant.components.vlc python-vlc==1.1.2 @@ -1954,9 +1972,6 @@ pytouchline==0.7 # homeassistant.components.traccar pytraccar==0.9.0 -# homeassistant.components.trackr -pytrackr==0.0.5 - # homeassistant.components.tradfri pytradfri[async]==7.0.6 @@ -2106,7 +2121,7 @@ screenlogicpy==0.4.1 scsgate==0.1.0 # homeassistant.components.sendgrid -sendgrid==6.7.0 +sendgrid==6.8.2 # homeassistant.components.sensehat sense-hat==2.2.0 @@ -2116,7 +2131,7 @@ sense-hat==2.2.0 sense_energy==0.9.2 # homeassistant.components.sentry -sentry-sdk==1.3.0 +sentry-sdk==1.4.1 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2172,7 +2187,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.23.3 +soco==0.24.0 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2245,7 +2260,7 @@ sucks==0.9.4 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.7.0 +surepy==0.7.2 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 @@ -2254,7 +2269,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==2.0.6 +systembridge==2.1.0 # homeassistant.components.tahoma tahoma-api==0.0.16 @@ -2284,10 +2299,7 @@ temperusb==1.5.3 # tensorflow==2.3.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.10 - -# homeassistant.components.tesla -teslajsonpy==0.18.3 +tesla-powerwall==0.3.11 # homeassistant.components.tensorflow # tf-models-official==2.3.0 @@ -2308,7 +2320,7 @@ tmb==0.0.4 todoist-python==8.0.0 # homeassistant.components.toon -toonapi==0.2.0 +toonapi==0.2.1 # homeassistant.components.totalconnect total_connect_client==0.57 @@ -2320,7 +2332,7 @@ tp-connected==0.0.4 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.10 +tuya-iot-py-sdk==0.5.0 # homeassistant.components.twentemilieu twentemilieu==0.3.0 @@ -2356,6 +2368,9 @@ uvcclient==0.11.0 # homeassistant.components.vallox vallox-websocket-api==2.8.1 +# homeassistant.components.velbus +velbus-aio==2021.9.4 + # homeassistant.components.venstar venstarcolortouch==0.14 @@ -2388,7 +2403,7 @@ wallbox==0.4.4 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.1.4 +watchdog==2.1.5 # homeassistant.components.waterfurnace waterfurnace==1.1.0 @@ -2396,6 +2411,9 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.whirlpool +whirlpool-sixth-sense==0.15.1 + # homeassistant.components.wiffi wiffi==1.0.1 @@ -2441,13 +2459,13 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.4 +yeelight==0.7.6 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 # homeassistant.components.youless -youless-api==0.12 +youless-api==0.13 # homeassistant.components.media_extractor youtube_dl==2021.04.26 @@ -2456,10 +2474,10 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.2 +zeroconf==0.36.7 # homeassistant.components.zha -zha-quirks==0.0.60 +zha-quirks==0.0.62 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2467,9 +2485,6 @@ zhong_hong_hvac==1.0.9 # homeassistant.components.ziggo_mediabox_xl ziggo-mediabox-xl==1.1.0 -# homeassistant.components.zha -zigpy-cc==0.5.2 - # homeassistant.components.zha zigpy-deconz==0.13.0 @@ -2483,10 +2498,10 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.37.1 +zigpy==0.38.0 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.30.0 +zwave-js-server-python==0.31.3 diff --git a/requirements_test.txt b/requirements_test.txt index 86114cc02b1..8f593777d82 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,17 +12,17 @@ coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 -pre-commit==2.14.0 -pylint==2.10.2 -pipdeptree==1.0.0 +pre-commit==2.15.0 +pylint==2.11.1 +pipdeptree==2.1.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 pytest-cov==2.12.1 pytest-test-groups==1.0.3 pytest-sugar==0.9.4 pytest-timeout==1.4.2 -pytest-xdist==2.2.1 -pytest==6.2.4 +pytest-xdist==2.4.0 +pytest==6.2.5 requests_mock==1.9.2 responses==0.12.0 respx==0.17.0 @@ -32,12 +32,10 @@ types-croniter==1.0.0 types-backports==0.1.3 types-certifi==0.1.4 types-chardet==0.1.5 -types-cryptography==3.3.2 types-decorator==0.1.7 types-emoji==1.2.4 types-enum34==0.1.8 types-ipaddress==0.1.5 -types-jwt==0.1.3 types-pkg-resources==0.1.3 types-python-slugify==0.1.2 types-pytz==2021.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e48ce94e0b..0d00bf4a27b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==4.1.0 +HAP-python==4.2.1 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -23,11 +23,14 @@ PyQRCode==1.2.1 # homeassistant.components.rmvtransport PyRMVtransport==0.3.2 +# homeassistant.components.switchbot +# PySwitchbot==0.11.0 + # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.5.2 +PyTurboJPEG==1.6.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -39,7 +42,7 @@ RtmAPI==0.7.2 WSDiscovery==2.0.0 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.12 +WazeRouteCalculator==0.13 # homeassistant.components.abode abodepy==1.2.0 @@ -90,7 +93,7 @@ aioazuredevops==1.3.5 aiobotocore==1.2.2 # homeassistant.components.dhcp -aiodiscover==1.4.2 +aiodiscover==1.4.4 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -106,7 +109,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==8.0.0 +aioesphomeapi==9.1.5 # homeassistant.components.flo aioflo==0.4.1 @@ -118,14 +121,14 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.2 +aiohomekit==0.6.3 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.1 +aiohue==2.6.3 # homeassistant.components.apache_kafka aiokafka==0.6.0 @@ -142,6 +145,9 @@ aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast aiomusiccast==0.9.2 +# homeassistant.components.nanoleaf +aionanoleaf==0.0.2 + # homeassistant.components.notion aionotion==3.0.2 @@ -161,7 +167,7 @@ aiopylgtv==0.4.0 aiorecollect==1.0.8 # homeassistant.components.shelly -aioshelly==0.6.4 +aioshelly==1.0.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -173,7 +179,10 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==26 +aiounifi==27 + +# homeassistant.components.watttime +aiowatttime==0.1.1 # homeassistant.components.yandex_transport aioymaps==1.1.0 @@ -181,12 +190,18 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings +airthings_cloud==0.0.1 + # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 # homeassistant.components.ambee ambee==0.3.0 +# homeassistant.components.amberelectric +amberelectric==1.0.3 + # homeassistant.components.ambiclimate ambiclimate==0.2.1 @@ -197,7 +212,7 @@ androidtv[async]==0.0.60 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.4 +apprise==0.9.5.1 # homeassistant.components.aprs aprslib==0.6.46 @@ -209,7 +224,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.20.0 +async-upnp-client==0.22.5 # homeassistant.components.aurora auroranoaa==0.0.2 @@ -227,7 +242,7 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.27.0 +bellows==0.28.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.20 @@ -239,7 +254,7 @@ blebox_uniapi==1.3.3 blinkpy==0.17.0 # homeassistant.components.bond -bond-api==0.1.12 +bond-api==0.1.13 # homeassistant.components.bosch_shc boschshcpy==0.2.19 @@ -269,7 +284,7 @@ co2signal==0.4.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==5.0.1 +colorlog==6.4.1 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -285,6 +300,15 @@ coronavirus==1.1.1 # homeassistant.components.utility_meter croniter==1.0.6 +# homeassistant.components.crownstone +crownstone-cloud==1.4.8 + +# homeassistant.components.crownstone +crownstone-sse==2.0.2 + +# homeassistant.components.crownstone +crownstone-uart==2.1.0 + # homeassistant.components.datadog datadog==0.15.0 @@ -292,16 +316,15 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.1 +debugpy==1.4.3 # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect -# homeassistant.components.ssdp defusedxml==0.7.1 # homeassistant.components.denonavr -denonavr==0.10.8 +denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 @@ -322,10 +345,10 @@ dynalite_devices==0.1.46 elgato==2.1.1 # homeassistant.components.elkm1 -elkm1-lib==0.8.10 +elkm1-lib==1.0.0 # homeassistant.components.mobile_app -emoji==1.2.0 +emoji==1.5.0 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -334,7 +357,7 @@ emulated_roku==0.2.1 enocean==0.50 # homeassistant.components.enphase_envoy -envoy_reader==0.19.0 +envoy_reader==0.20.0 # homeassistant.components.season ephem==3.7.7.0 @@ -349,7 +372,7 @@ faadelays==0.0.7 feedparser==6.0.2 # homeassistant.components.fjaraskupan -fjaraskupan==1.0.0 +fjaraskupan==1.0.1 # homeassistant.components.flipr flipr-api==1.4.1 @@ -405,7 +428,7 @@ gios==2.0.0 glances_api==0.2.0 # homeassistant.components.goalzero -goalzero==0.1.7 +goalzero==0.2.0 # homeassistant.components.google google-api-python-client==1.6.4 @@ -441,7 +464,7 @@ habitipy==0.2.0 hangups==0.4.14 # homeassistant.components.cloud -hass-nabucasa==0.46.0 +hass-nabucasa==0.50.0 # homeassistant.components.tasmota hatasmota==0.2.20 @@ -459,10 +482,10 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.2 +holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20210830.0 +home-assistant-frontend==20211006.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -505,7 +528,7 @@ influxdb-client==1.14.0 influxdb==5.2.3 # homeassistant.components.iotawatt -iotawattpy==0.0.8 +iotawattpy==0.1.0 # homeassistant.components.gogogate2 ismartgate==4.0.0 @@ -562,13 +585,13 @@ mficlient==0.3.0 micloud==0.3 # homeassistant.components.mill -millheater==0.5.2 +millheater==0.6.0 # homeassistant.components.minio minio==4.0.9 # homeassistant.components.motion_blinds -motionblinds==0.4.10 +motionblinds==0.5.5 # homeassistant.components.motioneye motioneye-client==0.3.11 @@ -589,7 +612,7 @@ ndms2_client==0.1.1 nessclient==0.9.15 # homeassistant.components.discovery -netdisco==2.9.0 +netdisco==3.0.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -620,7 +643,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.1 +numpy==1.21.2 # homeassistant.components.google oauth2client==4.0.0 @@ -637,6 +660,9 @@ ondilo==0.2.0 # homeassistant.components.onvif onvif-zeep-async==1.2.0 +# homeassistant.components.opengarage +open-garage==0.1.5 + # homeassistant.components.openerz openerz-api==0.1.0 @@ -662,6 +688,9 @@ pdunehd==1.3.2 # homeassistant.components.unifi_direct pexpect==4.6.0 +# homeassistant.components.modem_callerid +phone_modem==0.1.1 + # homeassistant.components.onewire pi1wire==0.1.0 @@ -700,7 +729,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.reddit -praw==7.2.0 +praw==7.4.0 # homeassistant.components.islamic_prayer_times prayer_times_calculator==0.0.5 @@ -735,9 +764,6 @@ py17track==3.2.1 # homeassistant.components.control4 pyControl4==0.0.6 -# homeassistant.components.tplink -pyHS100==0.3.5.2 - # homeassistant.components.met_eireann pyMetEireann==2021.8.0 @@ -749,7 +775,7 @@ pyMetno==0.8.3 pyRFXtrx==0.27.0 # homeassistant.components.tibber -pyTibber==0.19.0 +pyTibber==0.19.1 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -773,7 +799,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.3 +pyatmo==6.1.0 # homeassistant.components.apple_tv pyatv==0.8.2 @@ -803,7 +829,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.4.4 # homeassistant.components.deconz -pydeconz==83 +pydeconz==84 # homeassistant.components.dexcom pydexcom==0.2.0 @@ -814,6 +840,9 @@ pydispatcher==2.0.5 # homeassistant.components.econet pyeconet==0.1.14 +# homeassistant.components.efergy +pyefergy==0.0.3 + # homeassistant.components.everlights pyeverlights==0.1.0 @@ -827,7 +856,7 @@ pyfido==2.1.1 pyfireservicerota==0.0.43 # homeassistant.components.flume -pyflume==0.5.5 +pyflume==0.6.5 # homeassistant.components.flunearyou pyflunearyou==2.0.2 @@ -900,7 +929,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lastfm -pylast==4.2.0 +pylast==4.2.1 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 @@ -924,7 +953,7 @@ pymata-express==1.19 pymazda==0.2.1 # homeassistant.components.melcloud -pymelcloud==2.5.3 +pymelcloud==2.5.4 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 @@ -947,8 +976,8 @@ pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 -# homeassistant.components.nanoleaf -pynanoleaf==0.1.0 +# homeassistant.components.netgear +pynetgear==0.7.0 # homeassistant.components.nuki pynuki==1.4.1 @@ -957,7 +986,7 @@ pynuki==1.4.1 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.3.0 +pynws==1.3.1 # homeassistant.components.nx584 pynx584==0.5 @@ -992,7 +1021,7 @@ pypck==0.7.10 pyplaato==0.0.15 # homeassistant.components.point -pypoint==2.1.0 +pypoint==2.2.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1020,21 +1049,22 @@ pyruckus==0.12 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.crownstone # homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 # homeassistant.components.sia -pysiaalarm==3.0.0 +pysiaalarm==3.0.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.5 +pysma==0.6.6 # homeassistant.components.smappee -pysmappee==0.2.25 +pysmappee==0.2.27 # homeassistant.components.smartthings pysmartapp==0.3.3 @@ -1052,7 +1082,7 @@ pyspcwebgw==0.4.0 pysqueezebox==0.5.5 # homeassistant.components.syncthru -pysyncthru==0.7.3 +pysyncthru==0.7.10 # homeassistant.components.ecobee python-ecobee-api==0.2.11 @@ -1066,6 +1096,9 @@ python-izone==1.1.6 # homeassistant.components.juicenet python-juicenet==1.0.2 +# homeassistant.components.tplink +python-kasa==0.4.0 + # homeassistant.components.xiaomi_miio python-miio==0.5.8 @@ -1079,20 +1112,17 @@ python-openzwave-mqtt[mqtt-client]==1.4.0 python-picnic-api==1.1.0 # homeassistant.components.smarttub -python-smarttub==0.0.25 +python-smarttub==0.0.27 # homeassistant.components.songpal python-songpal==0.12 # homeassistant.components.tado -python-tado==0.10.0 +python-tado==0.12.0 # homeassistant.components.twitch python-twitch-client==0.6.0 -# homeassistant.components.velbus -python-velbus==2.1.2 - # homeassistant.components.awair python_awair==0.2.1 @@ -1185,7 +1215,7 @@ screenlogicpy==0.4.1 sense_energy==0.9.2 # homeassistant.components.sentry -sentry-sdk==1.3.0 +sentry-sdk==1.4.1 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1212,7 +1242,7 @@ smarthab==0.21 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.23.3 +soco==0.24.0 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1264,22 +1294,19 @@ subarulink==0.3.12 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.7.0 +surepy==0.7.2 # homeassistant.components.system_bridge -systembridge==2.0.6 +systembridge==2.1.0 # homeassistant.components.tellduslive tellduslive==0.10.11 # homeassistant.components.powerwall -tesla-powerwall==0.3.10 - -# homeassistant.components.tesla -teslajsonpy==0.18.3 +tesla-powerwall==0.3.11 # homeassistant.components.toon -toonapi==0.2.0 +toonapi==0.2.1 # homeassistant.components.totalconnect total_connect_client==0.57 @@ -1288,7 +1315,7 @@ total_connect_client==0.57 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.10 +tuya-iot-py-sdk==0.5.0 # homeassistant.components.twentemilieu twentemilieu==0.3.0 @@ -1315,6 +1342,9 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.velbus +velbus-aio==2021.9.4 + # homeassistant.components.venstar venstarcolortouch==0.14 @@ -1335,7 +1365,10 @@ wakeonlan==2.0.1 wallbox==0.4.4 # homeassistant.components.folder_watcher -watchdog==2.1.4 +watchdog==2.1.5 + +# homeassistant.components.whirlpool +whirlpool-sixth-sense==0.15.1 # homeassistant.components.wiffi wiffi==1.0.1 @@ -1370,19 +1403,16 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.4 +yeelight==0.7.6 # homeassistant.components.youless -youless-api==0.12 +youless-api==0.13 # homeassistant.components.zeroconf -zeroconf==0.36.2 +zeroconf==0.36.7 # homeassistant.components.zha -zha-quirks==0.0.60 - -# homeassistant.components.zha -zigpy-cc==0.5.2 +zha-quirks==0.0.62 # homeassistant.components.zha zigpy-deconz==0.13.0 @@ -1397,7 +1427,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.37.1 +zigpy==0.38.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.30.0 +zwave-js-server-python==0.31.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index e89785c25a8..aa3279cea18 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.7b0 +black==21.9b0 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.23.3 +pyupgrade==2.27.0 yamllint==1.26.1 diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index d039fc04c86..119a90ea3c6 100644 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -2,7 +2,19 @@ # ============================================================================== # Take down the S6 supervision tree when Home Assistant fails # ============================================================================== -if { s6-test ${1} -ne 100 } -if { s6-test ${1} -ne 256 } +define HA_RESTART_EXIT_CODE 100 +define SIGNAL_EXIT_CODE 256 +define SIGTERM 15 + +foreground { s6-echo "[finish] process exit code ${1}" } + +if { s6-test ${1} -ne ${HA_RESTART_EXIT_CODE} } +ifelse { s6-test ${1} -eq ${SIGNAL_EXIT_CODE} } { + # Process terminated by a signal + define signal ${2} + foreground { s6-echo "[finish] process received signal ${signal}" } + if { s6-test ${signal} -ne ${SIGTERM} } + s6-svscanctl -t /var/run/s6/services +} s6-svscanctl -t /var/run/s6/services diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f535958412d..939806d379e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -45,6 +45,10 @@ COMMENT_REQUIREMENTS = ( "VL53L1X2", ) +COMMENT_REQUIREMENTS_NORMALIZED = { + commented.lower().replace("_", "-") for commented in COMMENT_REQUIREMENTS +} + IGNORE_PIN = ("colorlog>2.1,<3", "urllib3") URL_PIN = ( @@ -98,6 +102,10 @@ pandas==1.3.0 # https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error # This is fixed in 2021.8.28 regex==2021.8.28 + +# anyio has a bug that was fixed in 3.3.1 +# can remove after httpx/httpcore updates its anyio version pin +anyio>=3.3.1 """ IGNORE_PRE_COMMIT_HOOK_ID = ( @@ -108,6 +116,8 @@ IGNORE_PRE_COMMIT_HOOK_ID = ( "python-typing-update", ) +PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") + def has_tests(module: str): """Test if a module has tests. @@ -171,9 +181,24 @@ def gather_recursive_requirements(domain, seen=None): return reqs +def normalize_package_name(requirement: str) -> str: + """Return a normalized package name from a requirement string.""" + # This function is also used in hassfest. + match = PACKAGE_REGEX.search(requirement) + if not match: + return "" + + # pipdeptree needs lowercase and dash instead of underscore as separator + package = match.group(1).lower().replace("_", "-") + + return package + + def comment_requirement(req): """Comment out requirement. Some don't install on all systems.""" - return any(ign.lower() in req.lower() for ign in COMMENT_REQUIREMENTS) + return any( + normalize_package_name(req) == ign for ign in COMMENT_REQUIREMENTS_NORMALIZED + ) def gather_modules(): diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 1df09d6f0d5..b650d3232ac 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -134,14 +134,14 @@ IGNORE_VIOLATIONS = { # Demo ("demo", "manual"), ("demo", "openalpr_local"), - # Migration wizard from zwave to ozw. - "ozw", # Migration of settings from zeroconf to network ("network", "zeroconf"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", + # Migration wizard from zwave to zwave_js. + "zwave_js", } diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 0026be479a4..375c55fe84b 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -17,7 +17,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.awair.*", "homeassistant.components.blueprint.*", "homeassistant.components.bmw_connected_drive.*", - "homeassistant.components.cert_expiry.*", "homeassistant.components.climacell.*", "homeassistant.components.cloud.*", "homeassistant.components.config.*", @@ -26,11 +25,8 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.demo.*", "homeassistant.components.denonavr.*", "homeassistant.components.dhcp.*", - "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", - "homeassistant.components.elkm1.*", "homeassistant.components.enphase_envoy.*", - "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", "homeassistant.components.fireservicerota.*", "homeassistant.components.firmata.*", @@ -41,7 +37,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.geniushub.*", "homeassistant.components.glances.*", "homeassistant.components.google_assistant.*", - "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", "homeassistant.components.habitica.*", @@ -87,6 +82,7 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.mullvad.*", "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", + "homeassistant.components.netgear.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", "homeassistant.components.nsw_fuel_station.*", @@ -121,7 +117,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.solaredge.*", "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", - "homeassistant.components.sonarr.*", "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", "homeassistant.components.stt.*", @@ -130,9 +125,7 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.tado.*", "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", - "homeassistant.components.tesla.*", "homeassistant.components.toon.*", - "homeassistant.components.tplink.*", "homeassistant.components.unifi.*", "homeassistant.components.upnp.*", "homeassistant.components.vera.*", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index f72562f7f2f..4d111265b1e 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -15,7 +15,7 @@ from tqdm import tqdm from homeassistant.const import REQUIRED_PYTHON_VER import homeassistant.util.package as pkg_util -from script.gen_requirements_all import COMMENT_REQUIREMENTS +from script.gen_requirements_all import COMMENT_REQUIREMENTS, normalize_package_name from .model import Config, Integration @@ -48,18 +48,6 @@ IGNORE_VIOLATIONS = { } -def normalize_package_name(requirement: str) -> str: - """Return a normalized package name from a requirement string.""" - match = PACKAGE_REGEX.search(requirement) - if not match: - return "" - - # pipdeptree needs lowercase and dash instead of underscore as separator - package = match.group(1).lower().replace("_", "-") - - return package - - def validate(integrations: dict[str, Integration], config: Config): """Handle requirements for integrations.""" # Check if we are doing format-only validation. @@ -134,7 +122,7 @@ def validate_requirements(integration: Integration): f"Failed to normalize package name from requirement {req}", ) return - if package in IGNORE_PACKAGES: + if (package == ign for ign in IGNORE_PACKAGES): continue integration_requirements.add(req) integration_packages.add(package) diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 2f146dfe6e3..ab5c93364e1 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -8,7 +8,7 @@ from .const import DOMAIN # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS = ["light"] +PLATFORMS: list[str] = ["light"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py index ff9c5bfb848..3ed8d9d293f 100644 --- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py @@ -16,7 +16,7 @@ CLIENT_SECRET = "5678" async def test_full_flow( hass: HomeAssistant, - aiohttp_client, + hass_client_no_auth, aioclient_mock, current_request_with_host, ) -> None: @@ -47,7 +47,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index 16dc43f8d59..45c6adb4dcf 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -5,7 +5,10 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import AutomationActionType +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state from homeassistant.const import ( @@ -71,7 +74,7 @@ async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: AutomationActionType, - automation_info: dict, + automation_info: AutomationTriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" # TODO Implement your own logic to attach triggers. diff --git a/setup.py b/setup.py index 302eadbfcf6..464aa484fcb 100755 --- a/setup.py +++ b/setup.py @@ -36,22 +36,21 @@ REQUIRES = [ "astral==2.2", "async_timeout==3.0.1", "attrs==21.2.0", - "awesomeversion==21.4.0", + "awesomeversion==21.8.1", 'backports.zoneinfo;python_version<"3.9"', "bcrypt==3.1.7", - "certifi>=2020.12.5", - "ciso8601==2.1.3", + "certifi>=2021.5.30", + "ciso8601==2.2.0", "httpx==0.19.0", "jinja2==3.0.1", - "PyJWT==1.7.1", + "PyJWT==2.1.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==3.3.2", + "cryptography==3.4.8", "pip>=8.0.3,<20.3", "python-slugify==4.0.1", "pyyaml==5.4.1", - "requests==2.25.1", - "ruamel.yaml==0.15.100", - "voluptuous==0.12.1", + "requests==2.26.0", + "voluptuous==0.12.2", "voluptuous-serialize==2.4.0", "yarl==1.6.3", ] diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 4a763a6e995..4c3d93ede15 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -539,7 +539,7 @@ async def test_create_access_token(mock_hass): access_token = manager.async_create_access_token(refresh_token) assert access_token is not None assert refresh_token.jwt_key == jwt_key - jwt_payload = jwt.decode(access_token, jwt_key, algorithm=["HS256"]) + jwt_payload = jwt.decode(access_token, jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token.id assert ( jwt_payload["exp"] - jwt_payload["iat"] == timedelta(minutes=30).total_seconds() @@ -558,7 +558,7 @@ async def test_create_long_lived_access_token(mock_hass): ) assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - jwt_payload = jwt.decode(access_token, refresh_token.jwt_key, algorithm=["HS256"]) + jwt_payload = jwt.decode(access_token, refresh_token.jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token.id assert ( jwt_payload["exp"] - jwt_payload["iat"] == timedelta(days=300).total_seconds() @@ -610,7 +610,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass): assert jwt_key != jwt_key_2 rt = await manager.async_validate_access_token(access_token_2) - jwt_payload = jwt.decode(access_token_2, rt.jwt_key, algorithm=["HS256"]) + jwt_payload = jwt.decode(access_token_2, rt.jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token_2.id assert ( jwt_payload["exp"] - jwt_payload["iat"] == timedelta(days=3000).total_seconds() diff --git a/tests/common.py b/tests/common.py index 3d5e28be514..519b53cd991 100644 --- a/tests/common.py +++ b/tests/common.py @@ -771,10 +771,14 @@ class MockConfigEntry(config_entries.ConfigEntry): def add_to_hass(self, hass): """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self + hass.config_entries._domain_index.setdefault(self.domain, []).append( + self.entry_id + ) def add_to_manager(self, manager): """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self + manager._domain_index.setdefault(self.domain, []).append(self.entry_id) def patch_yaml_files(files_dict, endswith=True): diff --git a/tests/components/airthings/__init__.py b/tests/components/airthings/__init__.py new file mode 100644 index 00000000000..e331fb2f2c6 --- /dev/null +++ b/tests/components/airthings/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airthings integration.""" diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py new file mode 100644 index 00000000000..ad9d44a054a --- /dev/null +++ b/tests/components/airthings/test_config_flow.py @@ -0,0 +1,117 @@ +"""Test the Airthings config flow.""" +from unittest.mock import patch + +import airthings + +from homeassistant import config_entries, setup +from homeassistant.components.airthings.const import CONF_ID, CONF_SECRET, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_ID: "client_id", + CONF_SECRET: "secret", +} + + +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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("airthings.get_token", return_value="test_token",), patch( + "homeassistant.components.airthings.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"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Airthings" + assert result2["data"] == TEST_DATA + 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( + "airthings.get_token", + side_effect=airthings.AirthingsAuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +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( + "airthings.get_token", + side_effect=airthings.AirthingsConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + + first_entry = MockConfigEntry( + domain="airthings", + data=TEST_DATA, + unique_id=TEST_DATA[CONF_ID], + ) + first_entry.add_to_hass(hass) + + with patch("airthings.get_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/alexa/__init__.py b/tests/components/alexa/__init__.py index bc007fefb84..5b1706c15e2 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1,4 +1,5 @@ """Tests for the Alexa integration.""" +import re from uuid import uuid4 from homeassistant.components.alexa import config, smart_home @@ -162,7 +163,8 @@ async def assert_scene_controller_works( ) assert response["event"]["payload"]["cause"]["type"] == "VOICE_INTERACTION" assert "timestamp" in response["event"]["payload"] - + pattern = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.0Z" + assert re.search(pattern, response["event"]["payload"]["timestamp"]) if deactivate_service: await assert_request_calls_service( "Alexa.SceneController", @@ -175,6 +177,7 @@ async def assert_scene_controller_works( cause_type = response["event"]["payload"]["cause"]["type"] assert cause_type == "VOICE_INTERACTION" assert "timestamp" in response["event"]["payload"] + assert re.search(pattern, response["event"]["payload"]["timestamp"]) async def reported_properties(hass, endpoint): diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index dc93ed6d805..d4d6bec62a9 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -346,16 +346,14 @@ async def test_report_colored_temp_light_state(hass): async def test_report_fan_speed_state(hass): - """Test PercentageController, PowerLevelController, RangeController reports fan speed correctly.""" + """Test PercentageController, PowerLevelController reports fan speed correctly.""" hass.states.async_set( "fan.off", "off", { "friendly_name": "Off fan", - "speed": "off", "supported_features": 1, "percentage": 0, - "speed_list": ["off", "low", "medium", "high"], }, ) hass.states.async_set( @@ -363,10 +361,8 @@ async def test_report_fan_speed_state(hass): "on", { "friendly_name": "Low speed fan", - "speed": "low", "supported_features": 1, "percentage": 33, - "speed_list": ["off", "low", "medium", "high"], }, ) hass.states.async_set( @@ -374,10 +370,8 @@ async def test_report_fan_speed_state(hass): "on", { "friendly_name": "Medium speed fan", - "speed": "medium", "supported_features": 1, "percentage": 66, - "speed_list": ["off", "low", "medium", "high"], }, ) hass.states.async_set( @@ -385,32 +379,43 @@ async def test_report_fan_speed_state(hass): "on", { "friendly_name": "High speed fan", - "speed": "high", "supported_features": 1, "percentage": 100, - "speed_list": ["off", "low", "medium", "high"], }, ) - + hass.states.async_set( + "fan.speed_less_on", + "on", + { + "friendly_name": "Speedless fan on", + "supported_features": 0, + }, + ) + hass.states.async_set( + "fan.speed_less_off", + "off", + { + "friendly_name": "Speedless fan off", + "supported_features": 0, + }, + ) properties = await reported_properties(hass, "fan.off") - properties.assert_equal("Alexa.PercentageController", "percentage", 0) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 0) properties.assert_equal("Alexa.RangeController", "rangeValue", 0) properties = await reported_properties(hass, "fan.low_speed") - properties.assert_equal("Alexa.PercentageController", "percentage", 33) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 33) - properties.assert_equal("Alexa.RangeController", "rangeValue", 1) + properties.assert_equal("Alexa.RangeController", "rangeValue", 33) properties = await reported_properties(hass, "fan.medium_speed") - properties.assert_equal("Alexa.PercentageController", "percentage", 66) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 66) - properties.assert_equal("Alexa.RangeController", "rangeValue", 2) + properties.assert_equal("Alexa.RangeController", "rangeValue", 66) properties = await reported_properties(hass, "fan.high_speed") - properties.assert_equal("Alexa.PercentageController", "percentage", 100) - properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 100) - properties.assert_equal("Alexa.RangeController", "rangeValue", 3) + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "fan.speed_less_on") + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "fan.speed_less_off") + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) async def test_report_fan_preset_mode(hass): @@ -454,6 +459,18 @@ async def test_report_fan_preset_mode(hass): properties = await reported_properties(hass, "fan.preset_mode") properties.assert_equal("Alexa.ModeController", "mode", "preset_mode.whoosh") + hass.states.async_set( + "fan.preset_mode", + "whoosh", + { + "friendly_name": "one preset mode fan", + "supported_features": 8, + "preset_mode": "auto", + "preset_modes": ["auto"], + }, + ) + properties = await reported_properties(hass, "fan.preset_mode") + async def test_report_fan_oscillating(hass): """Test ToggleController reports fan oscillating correctly.""" diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0da21042049..71123ca27ba 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -365,14 +365,42 @@ async def test_fan(hass): assert appliance["endpointId"] == "fan#test_1" assert appliance["displayCategories"][0] == "FAN" assert appliance["friendlyName"] == "Test fan 1" + # Alexa.RangeController is added to make a van controllable when no other controllers are available capabilities = assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" + appliance, + "Alexa.RangeController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", ) power_capability = get_capability(capabilities, "Alexa.PowerController") assert "capabilityResources" not in power_capability assert "configuration" not in power_capability + await assert_power_controller_works( + "fan#test_1", "fan.turn_on", "fan.turn_off", hass + ) + + await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_1", + "fan.turn_on", + hass, + payload={"rangeValue": "100"}, + instance="fan.percentage", + ) + await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_1", + "fan.turn_off", + hass, + payload={"rangeValue": "0"}, + instance="fan.percentage", + ) + async def test_variable_fan(hass): """Test fan discovery. @@ -385,8 +413,6 @@ async def test_variable_fan(hass): { "friendly_name": "Test fan 2", "supported_features": 1, - "speed_list": ["low", "medium", "high"], - "speed": "high", "percentage": 100, }, ) @@ -398,113 +424,133 @@ async def test_variable_fan(hass): capabilities = assert_endpoint_capabilities( appliance, - "Alexa.PercentageController", - "Alexa.PowerController", - "Alexa.PowerLevelController", "Alexa.RangeController", + "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", ) - range_capability = get_capability(capabilities, "Alexa.RangeController") - assert range_capability is not None - assert range_capability["instance"] == "fan.speed" + capability = get_capability(capabilities, "Alexa.RangeController") + assert capability is not None - properties = range_capability["properties"] - assert properties["nonControllable"] is False - assert {"name": "rangeValue"} in properties["supported"] - - capability_resources = range_capability["capabilityResources"] - assert capability_resources is not None - assert { - "@type": "asset", - "value": {"assetId": "Alexa.Setting.FanSpeed"}, - } in capability_resources["friendlyNames"] - - configuration = range_capability["configuration"] - assert configuration is not None + capability = get_capability(capabilities, "Alexa.PowerController") + assert capability is not None call, _ = await assert_request_calls_service( - "Alexa.PercentageController", - "SetPercentage", + "Alexa.RangeController", + "SetRangeValue", "fan#test_2", "fan.set_percentage", hass, - payload={"percentage": "50"}, + payload={"rangeValue": "50"}, + instance="fan.percentage", ) assert call.data["percentage"] == 50 call, _ = await assert_request_calls_service( - "Alexa.PercentageController", - "SetPercentage", + "Alexa.RangeController", + "SetRangeValue", "fan#test_2", "fan.set_percentage", hass, - payload={"percentage": "33"}, + payload={"rangeValue": "33"}, + instance="fan.percentage", ) assert call.data["percentage"] == 33 call, _ = await assert_request_calls_service( - "Alexa.PercentageController", - "SetPercentage", + "Alexa.RangeController", + "SetRangeValue", "fan#test_2", "fan.set_percentage", hass, - payload={"percentage": "100"}, + payload={"rangeValue": "100"}, + instance="fan.percentage", ) assert call.data["percentage"] == 100 - await assert_percentage_changes( + await assert_range_changes( hass, - [(95, "-5"), (100, "5"), (20, "-80"), (66, "-34")], - "Alexa.PercentageController", - "AdjustPercentage", + [ + (95, -5, False), + (100, 5, False), + (20, -80, False), + (66, -34, False), + (80, -1, True), + (20, -4, True), + ], + "Alexa.RangeController", + "AdjustRangeValue", "fan#test_2", - "percentageDelta", "fan.set_percentage", "percentage", + "fan.percentage", + ) + await assert_range_changes( + hass, + [ + (0, -100, False), + ], + "Alexa.RangeController", + "AdjustRangeValue", + "fan#test_2", + "fan.turn_off", + None, + "fan.percentage", ) - call, _ = await assert_request_calls_service( - "Alexa.PowerLevelController", - "SetPowerLevel", - "fan#test_2", - "fan.set_percentage", - hass, - payload={"powerLevel": "20"}, - ) - assert call.data["percentage"] == 20 - call, _ = await assert_request_calls_service( - "Alexa.PowerLevelController", - "SetPowerLevel", - "fan#test_2", - "fan.set_percentage", - hass, - payload={"powerLevel": "50"}, - ) - assert call.data["percentage"] == 50 +async def test_variable_fan_no_current_speed(hass, caplog): + """Test fan discovery. - call, _ = await assert_request_calls_service( - "Alexa.PowerLevelController", - "SetPowerLevel", - "fan#test_2", - "fan.set_percentage", - hass, - payload={"powerLevel": "99"}, + This one has variable speed, but no current speed. + """ + device = ( + "fan.test_3", + "off", + { + "friendly_name": "Test fan 3", + "supported_features": 1, + "percentage": None, + }, ) - assert call.data["percentage"] == 99 + appliance = await discovery_test(device, hass) - await assert_percentage_changes( - hass, - [(95, "-5"), (50, "-50"), (20, "-80")], - "Alexa.PowerLevelController", - "AdjustPowerLevel", - "fan#test_2", - "powerLevelDelta", - "fan.set_percentage", - "percentage", + assert appliance["endpointId"] == "fan#test_3" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 3" + # Alexa.RangeController is added to make a van controllable when no other controllers are available + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", ) + capability = get_capability(capabilities, "Alexa.RangeController") + assert capability is not None + + capability = get_capability(capabilities, "Alexa.PowerController") + assert capability is not None + + with pytest.raises(AssertionError): + await assert_range_changes( + hass, + [ + (20, -5, False), + ], + "Alexa.RangeController", + "AdjustRangeValue", + "fan#test_3", + "fan.set_percentage", + "percentage", + "fan.percentage", + ) + assert ( + "Request Alexa.RangeController/AdjustRangeValue error INVALID_VALUE: Unable to determine fan.test_3 current fan speed" + in caplog.text + ) + caplog.clear() async def test_oscillating_fan(hass): @@ -671,181 +717,6 @@ async def test_direction_fan(hass): assert call.data -async def test_fan_range(hass): - """Test fan speed with rangeController.""" - device = ( - "fan.test_5", - "off", - { - "friendly_name": "Test fan 5", - "supported_features": 1, - "speed_list": ["off", "low", "medium", "high", "turbo", 5, "warp_speed"], - "speed": "medium", - }, - ) - appliance = await discovery_test(device, hass) - - assert appliance["endpointId"] == "fan#test_5" - assert appliance["displayCategories"][0] == "FAN" - assert appliance["friendlyName"] == "Test fan 5" - - capabilities = assert_endpoint_capabilities( - appliance, - "Alexa.PercentageController", - "Alexa.PowerController", - "Alexa.PowerLevelController", - "Alexa.RangeController", - "Alexa.EndpointHealth", - "Alexa", - ) - - range_capability = get_capability(capabilities, "Alexa.RangeController") - assert range_capability is not None - assert range_capability["instance"] == "fan.speed" - - capability_resources = range_capability["capabilityResources"] - assert capability_resources is not None - assert { - "@type": "asset", - "value": {"assetId": "Alexa.Setting.FanSpeed"}, - } in capability_resources["friendlyNames"] - - configuration = range_capability["configuration"] - assert configuration is not None - - supported_range = configuration["supportedRange"] - assert supported_range["minimumValue"] == 0 - assert supported_range["maximumValue"] == 6 - assert supported_range["precision"] == 1 - - presets = configuration["presets"] - assert { - "rangeValue": 0, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "off", "locale": "en-US"}} - ] - }, - } in presets - - assert { - "rangeValue": 1, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "low", "locale": "en-US"}}, - {"@type": "asset", "value": {"assetId": "Alexa.Value.Minimum"}}, - ] - }, - } in presets - - assert { - "rangeValue": 2, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "medium", "locale": "en-US"}} - ] - }, - } in presets - - assert {"rangeValue": 5} not in presets - - assert { - "rangeValue": 6, - "presetResources": { - "friendlyNames": [ - {"@type": "text", "value": {"text": "warp speed", "locale": "en-US"}}, - {"@type": "asset", "value": {"assetId": "Alexa.Value.Maximum"}}, - ] - }, - } in presets - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_5", - "fan.set_speed", - hass, - payload={"rangeValue": 1}, - instance="fan.speed", - ) - assert call.data["speed"] == "low" - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_5", - "fan.set_speed", - hass, - payload={"rangeValue": 5}, - instance="fan.speed", - ) - assert call.data["speed"] == 5 - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_5", - "fan.set_speed", - hass, - payload={"rangeValue": 6}, - instance="fan.speed", - ) - assert call.data["speed"] == "warp_speed" - - await assert_range_changes( - hass, - [ - ("low", -1, False), - ("high", 1, False), - ("medium", 0, False), - ("warp_speed", 99, False), - ], - "Alexa.RangeController", - "AdjustRangeValue", - "fan#test_5", - "fan.set_speed", - "speed", - instance="fan.speed", - ) - - -async def test_fan_range_off(hass): - """Test fan range controller 0 turns_off fan.""" - device = ( - "fan.test_6", - "off", - { - "friendly_name": "Test fan 6", - "supported_features": 1, - "speed_list": ["off", "low", "medium", "high"], - "speed": "high", - }, - ) - await discovery_test(device, hass) - - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "fan#test_6", - "fan.turn_off", - hass, - payload={"rangeValue": 0}, - instance="fan.speed", - ) - assert call.data["speed"] == "off" - - await assert_range_changes( - hass, - [("off", -3, False), ("off", -99, False)], - "Alexa.RangeController", - "AdjustRangeValue", - "fan#test_6", - "fan.turn_off", - "speed", - instance="fan.speed", - ) - - async def test_preset_mode_fan(hass, caplog): """Test fan discovery. @@ -929,6 +800,78 @@ async def test_preset_mode_fan(hass, caplog): caplog.clear() +async def test_single_preset_mode_fan(hass, caplog): + """Test fan discovery. + + This one has only preset mode. + """ + device = ( + "fan.test_8", + "off", + { + "friendly_name": "Test fan 8", + "supported_features": 8, + "preset_modes": ["auto"], + "preset_mode": "auto", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "fan#test_8" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 8" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.EndpointHealth", + "Alexa.ModeController", + "Alexa.PowerController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.ModeController") + assert range_capability is not None + assert range_capability["instance"] == "fan.preset_mode" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_8", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.auto"}, + instance="fan.preset_mode", + ) + assert call.data["preset_mode"] == "auto" + + with pytest.raises(AssertionError): + await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_8", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.-"}, + instance="fan.preset_mode", + ) + assert "Entity 'fan.test_8' does not support Preset '-'" in caplog.text + caplog.clear() + + async def test_lock(hass): """Test lock discovery.""" device = ("lock.test", "off", {"friendly_name": "Test lock"}) @@ -1802,7 +1745,8 @@ async def assert_range_changes( call, _ = await assert_request_calls_service( namespace, name, endpoint, service, hass, payload=payload, instance=instance ) - assert call.data[changed_parameter] == result_range + if changed_parameter: + assert call.data[changed_parameter] == result_range async def test_temp_sensor(hass): diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index bbe80f29eef..bd91dc8f846 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -51,8 +51,6 @@ async def test_report_state_instance(hass, aioclient_mock): { "friendly_name": "Test fan", "supported_features": 15, - "speed": None, - "speed_list": ["off", "low", "high"], "oscillating": False, "preset_mode": None, "preset_modes": ["auto", "smart"], @@ -68,8 +66,6 @@ async def test_report_state_instance(hass, aioclient_mock): { "friendly_name": "Test fan", "supported_features": 15, - "speed": "high", - "speed_list": ["off", "low", "high"], "oscillating": True, "preset_mode": "smart", "preset_modes": ["auto", "smart"], @@ -101,20 +97,12 @@ async def test_report_state_instance(hass, aioclient_mock): assert report["instance"] == "fan.preset_mode" assert report["namespace"] == "Alexa.ModeController" checks += 1 - if report["name"] == "percentage": - assert report["value"] == 90 - assert report["namespace"] == "Alexa.PercentageController" - checks += 1 - if report["name"] == "powerLevel": - assert report["value"] == 90 - assert report["namespace"] == "Alexa.PowerLevelController" - checks += 1 if report["name"] == "rangeValue": - assert report["value"] == 2 - assert report["instance"] == "fan.speed" + assert report["value"] == 90 + assert report["instance"] == "fan.percentage" assert report["namespace"] == "Alexa.RangeController" checks += 1 - assert checks == 5 + assert checks == 3 assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan" diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index b5a3d90fbdb..bd1f23d956c 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -92,7 +92,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -127,7 +127,7 @@ async def test_full_flow( f"&state={state}&scope=profile+user-read+user-read-results+user-exec-command" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py new file mode 100644 index 00000000000..fbb1ebfd7ad --- /dev/null +++ b/tests/components/amberelectric/helpers.py @@ -0,0 +1,121 @@ +"""Some common test functions for testing Amber components.""" + +from datetime import datetime, timedelta + +from amberelectric.model.actual_interval import ActualInterval +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.forecast_interval import ForecastInterval +from amberelectric.model.interval import SpikeStatus +from dateutil import parser + + +def generate_actual_interval( + channel_type: ChannelType, end_time: datetime +) -> ActualInterval: + """Generate a mock actual interval.""" + start_time = end_time - timedelta(minutes=30) + return ActualInterval( + duration=30, + spot_per_kwh=1.0, + per_kwh=8.0, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50, + channel_type=channel_type.value, + spike_status=SpikeStatus.NO_SPIKE.value, + ) + + +def generate_current_interval( + channel_type: ChannelType, end_time: datetime +) -> CurrentInterval: + """Generate a mock current price.""" + start_time = end_time - timedelta(minutes=30) + return CurrentInterval( + duration=30, + spot_per_kwh=1.0, + per_kwh=8.0, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50.6, + channel_type=channel_type.value, + spike_status=SpikeStatus.NO_SPIKE.value, + estimate=True, + ) + + +def generate_forecast_interval( + channel_type: ChannelType, end_time: datetime +) -> ForecastInterval: + """Generate a mock forecast interval.""" + start_time = end_time - timedelta(minutes=30) + return ForecastInterval( + duration=30, + spot_per_kwh=1.1, + per_kwh=8.8, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50, + channel_type=channel_type.value, + spike_status=SpikeStatus.NO_SPIKE.value, + estimate=True, + ) + + +GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" +GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" +GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" +GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" + +GENERAL_CHANNEL = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00") + ), +] + +CONTROLLED_LOAD_CHANNEL = [ + generate_current_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:00:00+10:00") + ), + generate_forecast_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T10:00:00+10:00") + ), +] + + +FEED_IN_CHANNEL = [ + generate_current_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T09:00:00+10:00") + ), + generate_forecast_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T09:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.FEED_IN, parser.parse("2021-09-21T10:00:00+10:00") + ), +] diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py new file mode 100644 index 00000000000..9aa4782b9a4 --- /dev/null +++ b/tests/components/amberelectric/test_binary_sensor.py @@ -0,0 +1,140 @@ +"""Test the Amber Electric Sensors.""" +from __future__ import annotations + +from typing import AsyncGenerator +from unittest.mock import Mock, patch + +from amberelectric.model.channel import ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.interval import SpikeStatus +from dateutil import parser +import pytest + +from homeassistant.components.amberelectric.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.amberelectric.helpers import ( + GENERAL_CHANNEL, + GENERAL_ONLY_SITE_ID, + generate_current_interval, +) + +MOCK_API_TOKEN = "psk_0000000000000000" + + +@pytest.fixture +async def setup_no_spike(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock(return_value=GENERAL_CHANNEL) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_potential_spike(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.POTENTIAL + instance.get_current_price = Mock(return_value=general_channel) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_spike(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.SPIKE + instance.get_current_price = Mock(return_value=general_channel) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +def test_no_spike_sensor(hass: HomeAssistant, setup_no_spike) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("binary_sensor.mock_title_price_spike") + assert sensor + assert sensor.state == "off" + assert sensor.attributes["icon"] == "mdi:power-plug" + assert sensor.attributes["spike_status"] == "none" + + +def test_potential_spike_sensor(hass: HomeAssistant, setup_potential_spike) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("binary_sensor.mock_title_price_spike") + assert sensor + assert sensor.state == "off" + assert sensor.attributes["icon"] == "mdi:power-plug-outline" + assert sensor.attributes["spike_status"] == "potential" + + +def test_spike_sensor(hass: HomeAssistant, setup_spike) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("binary_sensor.mock_title_price_spike") + assert sensor + assert sensor.state == "on" + assert sensor.attributes["icon"] == "mdi:power-plug-off" + assert sensor.attributes["spike_status"] == "spike" diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py new file mode 100644 index 00000000000..71c40b4cf75 --- /dev/null +++ b/tests/components/amberelectric/test_config_flow.py @@ -0,0 +1,148 @@ +"""Tests for the Amber config flow.""" + +from typing import Generator +from unittest.mock import Mock, patch + +from amberelectric import ApiException +from amberelectric.model.site import Site +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + CONF_SITE_NMI, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +API_KEY = "psk_123456789" + + +@pytest.fixture(name="invalid_key_api") +def mock_invalid_key_api() -> Generator: + """Return an authentication error.""" + instance = Mock() + instance.get_sites.side_effect = ApiException(status=403) + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +@pytest.fixture(name="api_error") +def mock_api_error() -> Generator: + """Return an authentication error.""" + instance = Mock() + instance.get_sites.side_effect = ApiException(status=500) + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +@pytest.fixture(name="single_site_api") +def mock_single_site_api() -> Generator: + """Return a single site.""" + instance = Mock() + site = Site("01FG0AGP818PXK0DWHXJRRT2DH", "11111111111", []) + instance.get_sites.return_value = [site] + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +@pytest.fixture(name="no_site_api") +def mock_no_site_api() -> Generator: + """Return no site.""" + instance = Mock() + instance.get_sites.return_value = [] + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: + """Test single site.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_NMI: "11111111111", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + assert data[CONF_SITE_NMI] == "11111111111" + + +async def test_no_site(hass: HomeAssistant, no_site_api: Mock) -> None: + """Test no site.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: "psk_123456789"}, + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + # Goes back to the user step + assert result.get("step_id") == "user" + assert result.get("errors") == {"api_token": "no_site"} + + +async def test_invalid_key(hass: HomeAssistant, invalid_key_api: Mock) -> None: + """Test invalid api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == "user" + + # Test filling in API key + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: "psk_123456789"}, + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + # Goes back to the user step + assert result.get("step_id") == "user" + assert result.get("errors") == {"api_token": "invalid_api_token"} + + +async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None: + """Test invalid api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == "user" + + # Test filling in API key + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: "psk_123456789"}, + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + # Goes back to the user step + assert result.get("step_id") == "user" + assert result.get("errors") == {"api_token": "unknown_error"} diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py new file mode 100644 index 00000000000..5085f9c50f8 --- /dev/null +++ b/tests/components/amberelectric/test_coordinator.py @@ -0,0 +1,244 @@ +"""Tests for the Amber Electric Data Coordinator.""" +from __future__ import annotations + +from typing import Generator +from unittest.mock import Mock, patch + +from amberelectric import ApiException +from amberelectric.model.channel import Channel, ChannelType +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.interval import SpikeStatus +from amberelectric.model.site import Site +from dateutil import parser +import pytest + +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.components.amberelectric.helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_ONLY_SITE_ID, + generate_current_interval, +) + + +@pytest.fixture(name="current_price_api") +def mock_api_current_price() -> Generator: + """Return an authentication error.""" + instance = Mock() + + general_site = Site( + GENERAL_ONLY_SITE_ID, + "11111111111", + [Channel(identifier="E1", type=ChannelType.GENERAL)], + ) + general_and_controlled_load = Site( + GENERAL_AND_CONTROLLED_SITE_ID, + "11111111112", + [ + Channel(identifier="E1", type=ChannelType.GENERAL), + Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD), + ], + ) + general_and_feed_in = Site( + GENERAL_AND_FEED_IN_SITE_ID, + "11111111113", + [ + Channel(identifier="E1", type=ChannelType.GENERAL), + Channel(identifier="E2", type=ChannelType.FEED_IN), + ], + ) + instance.get_sites.return_value = [ + general_site, + general_and_controlled_load, + general_and_feed_in, + ] + + with patch("amberelectric.api.AmberApi.create", return_value=instance): + yield instance + + +async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: + """Test fetching a site with only a general channel.""" + + current_price_api.get_current_price.return_value = GENERAL_CHANNEL + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_ONLY_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" + + +async def test_fetch_no_general_site( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with no general channel.""" + + current_price_api.get_current_price.return_value = CONTROLLED_LOAD_CHANNEL + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + with pytest.raises(UpdateFailed): + await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_ONLY_SITE_ID, next=48 + ) + + +async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> None: + """Test that the old values are maintained if a second call fails.""" + + current_price_api.get_current_price.return_value = GENERAL_CHANNEL + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_ONLY_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + + current_price_api.get_current_price.side_effect = ApiException(status=403) + with pytest.raises(UpdateFailed): + await data_service._async_update_data() + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" + + +async def test_fetch_general_and_controlled_load_site( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with a general and controlled load channel.""" + + current_price_api.get_current_price.return_value = ( + GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL + ) + data_service = AmberUpdateCoordinator( + hass, current_price_api, GENERAL_AND_CONTROLLED_SITE_ID + ) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_AND_CONTROLLED_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is CONTROLLED_LOAD_CHANNEL[0] + assert result["forecasts"].get("controlled_load") == [ + CONTROLLED_LOAD_CHANNEL[1], + CONTROLLED_LOAD_CHANNEL[2], + CONTROLLED_LOAD_CHANNEL[3], + ] + assert result["current"].get("feed_in") is None + assert result["forecasts"].get("feed_in") is None + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" + + +async def test_fetch_general_and_feed_in_site( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with a general and feed_in channel.""" + + current_price_api.get_current_price.return_value = GENERAL_CHANNEL + FEED_IN_CHANNEL + data_service = AmberUpdateCoordinator( + hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID + ) + result = await data_service._async_update_data() + + current_price_api.get_current_price.assert_called_with( + GENERAL_AND_FEED_IN_SITE_ID, next=48 + ) + + assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["forecasts"].get("general") == [ + GENERAL_CHANNEL[1], + GENERAL_CHANNEL[2], + GENERAL_CHANNEL[3], + ] + assert result["current"].get("controlled_load") is None + assert result["forecasts"].get("controlled_load") is None + assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0] + assert result["forecasts"].get("feed_in") == [ + FEED_IN_CHANNEL[1], + FEED_IN_CHANNEL[2], + FEED_IN_CHANNEL[3], + ] + assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["price_spike"] == "none" + + +async def test_fetch_potential_spike( + hass: HomeAssistant, current_price_api: Mock +) -> None: + """Test fetching a site with only a general channel.""" + + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.POTENTIAL + current_price_api.get_current_price.return_value = general_channel + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + assert result["grid"]["price_spike"] == "potential" + + +async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None: + """Test fetching a site with only a general channel.""" + + general_channel: list[CurrentInterval] = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + ] + general_channel[0].spike_status = SpikeStatus.SPIKE + current_price_api.get_current_price.return_value = general_channel + data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + result = await data_service._async_update_data() + assert result["grid"]["price_spike"] == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py new file mode 100644 index 00000000000..ccfcd82b3bd --- /dev/null +++ b/tests/components/amberelectric/test_sensor.py @@ -0,0 +1,281 @@ +"""Test the Amber Electric Sensors.""" +from typing import AsyncGenerator, List +from unittest.mock import Mock, patch + +from amberelectric.model.current_interval import CurrentInterval +from amberelectric.model.range import Range +import pytest + +from homeassistant.components.amberelectric.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.amberelectric.helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_ONLY_SITE_ID, +) + +MOCK_API_TOKEN = "psk_0000000000000000" + + +@pytest.fixture +async def setup_general(hass) -> AsyncGenerator: + """Set up general channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock(return_value=GENERAL_CHANNEL) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_general_and_controlled_load(hass) -> AsyncGenerator: + """Set up general channel and controller load channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock( + return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_general_and_feed_in(hass) -> AsyncGenerator: + """Set up general channel and feed in channel.""" + MockConfigEntry( + domain="amberelectric", + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, + }, + ).add_to_hass(hass) + + instance = Mock() + with patch( + "amberelectric.api.AmberApi.create", + return_value=instance, + ) as mock_update: + instance.get_current_price = Mock( + return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: + """Test the General Price sensor.""" + assert len(hass.states.async_all()) == 4 + price = hass.states.get("sensor.mock_title_general_price") + assert price + assert price.state == "0.08" + attributes = price.attributes + assert attributes["duration"] == 30 + assert attributes["date"] == "2021-09-21" + assert attributes["per_kwh"] == 0.08 + assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" + assert attributes["spot_per_kwh"] == 0.01 + assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" + assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" + assert attributes["renewables"] == 51 + assert attributes["estimate"] is True + assert attributes["spike_status"] == "none" + assert attributes["channel_type"] == "general" + assert attributes["attribution"] == "Data provided by Amber Electric" + assert attributes.get("range_min") is None + assert attributes.get("range_max") is None + + with_range: List[CurrentInterval] = GENERAL_CHANNEL + with_range[0].range = Range(7.8, 12.4) + + setup_general.get_current_price.return_value = with_range + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + price = hass.states.get("sensor.mock_title_general_price") + assert price + attributes = price.attributes + assert attributes.get("range_min") == 0.08 + assert attributes.get("range_max") == 0.12 + + +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, setup_general_and_controlled_load: Mock +) -> None: + """Test the Controlled Price sensor.""" + assert len(hass.states.async_all()) == 6 + price = hass.states.get("sensor.mock_title_controlled_load_price") + assert price + assert price.state == "0.08" + attributes = price.attributes + assert attributes["duration"] == 30 + assert attributes["date"] == "2021-09-21" + assert attributes["per_kwh"] == 0.08 + assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" + assert attributes["spot_per_kwh"] == 0.01 + assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" + assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" + assert attributes["renewables"] == 51 + assert attributes["estimate"] is True + assert attributes["spike_status"] == "none" + assert attributes["channel_type"] == "controlledLoad" + assert attributes["attribution"] == "Data provided by Amber Electric" + + +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, setup_general_and_feed_in: Mock +) -> None: + """Test the Feed In sensor.""" + assert len(hass.states.async_all()) == 6 + print(hass.states) + price = hass.states.get("sensor.mock_title_feed_in_price") + assert price + assert price.state == "-0.08" + attributes = price.attributes + assert attributes["duration"] == 30 + assert attributes["date"] == "2021-09-21" + assert attributes["per_kwh"] == -0.08 + assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" + assert attributes["spot_per_kwh"] == 0.01 + assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" + assert attributes["end_time"] == "2021-09-21T08:30:00+10:00" + assert attributes["renewables"] == 51 + assert attributes["estimate"] is True + assert attributes["spike_status"] == "none" + assert attributes["channel_type"] == "feedIn" + assert attributes["attribution"] == "Data provided by Amber Electric" + + +async def test_general_forecast_sensor( + hass: HomeAssistant, setup_general: Mock +) -> None: + """Test the General Forecast sensor.""" + assert len(hass.states.async_all()) == 4 + price = hass.states.get("sensor.mock_title_general_forecast") + assert price + assert price.state == "0.09" + attributes = price.attributes + assert attributes["channel_type"] == "general" + assert attributes["attribution"] == "Data provided by Amber Electric" + + first_forecast = attributes["forecasts"][0] + assert first_forecast["duration"] == 30 + assert first_forecast["date"] == "2021-09-21" + assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["spot_per_kwh"] == 0.01 + assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" + assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["renewables"] == 50 + assert first_forecast["spike_status"] == "none" + + assert first_forecast.get("range_min") is None + assert first_forecast.get("range_max") is None + + with_range: List[CurrentInterval] = GENERAL_CHANNEL + with_range[1].range = Range(7.8, 12.4) + + setup_general.get_current_price.return_value = with_range + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + price = hass.states.get("sensor.mock_title_general_forecast") + assert price + attributes = price.attributes + first_forecast = attributes["forecasts"][0] + assert first_forecast.get("range_min") == 0.08 + assert first_forecast.get("range_max") == 0.12 + + +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, setup_general_and_controlled_load: Mock +) -> None: + """Test the Controlled Load Forecast sensor.""" + assert len(hass.states.async_all()) == 6 + price = hass.states.get("sensor.mock_title_controlled_load_forecast") + assert price + assert price.state == "0.09" + attributes = price.attributes + assert attributes["channel_type"] == "controlledLoad" + assert attributes["attribution"] == "Data provided by Amber Electric" + + first_forecast = attributes["forecasts"][0] + assert first_forecast["duration"] == 30 + assert first_forecast["date"] == "2021-09-21" + assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["spot_per_kwh"] == 0.01 + assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" + assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["renewables"] == 50 + assert first_forecast["spike_status"] == "none" + + +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, setup_general_and_feed_in: Mock +) -> None: + """Test the Feed In Forecast sensor.""" + assert len(hass.states.async_all()) == 6 + price = hass.states.get("sensor.mock_title_feed_in_forecast") + assert price + assert price.state == "-0.09" + attributes = price.attributes + assert attributes["channel_type"] == "feedIn" + assert attributes["attribution"] == "Data provided by Amber Electric" + + first_forecast = attributes["forecasts"][0] + assert first_forecast["duration"] == 30 + assert first_forecast["date"] == "2021-09-21" + assert first_forecast["per_kwh"] == -0.09 + assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["spot_per_kwh"] == 0.01 + assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" + assert first_forecast["end_time"] == "2021-09-21T09:00:00+10:00" + assert first_forecast["renewables"] == 50 + assert first_forecast["spike_status"] == "none" + + +def test_renewable_sensor(hass: HomeAssistant, setup_general) -> None: + """Testing the creation of the Amber renewables sensor.""" + assert len(hass.states.async_all()) == 4 + sensor = hass.states.get("sensor.mock_title_renewables") + assert sensor + assert sensor.state == "51" diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index ffda908a29b..400755c39cd 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -382,18 +382,20 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin_user): +async def test_api_error_log( + hass, hass_client_no_auth, hass_access_token, hass_admin_user +): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = "/some/path" await async_setup_component(hass, "api", {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(const.URL_API_ERROR_LOG) # Verify auth required assert resp.status == 401 with patch( - "aiohttp.web.FileResponse", return_value=web.Response(status=200, text="Hello") + "aiohttp.web.FileResponse", return_value=web.Response(text="Hello") ) as mock_file: resp = await client.get( const.URL_API_ERROR_LOG, @@ -557,3 +559,20 @@ async def test_api_call_service_bad_data(hass, mock_api_client): "/api/services/test_domain/test_service", json={"hello": 5} ) assert resp.status == 400 + + +async def test_api_get_discovery_info(hass, mock_api_client): + """Test the return of discovery info.""" + resp = await mock_api_client.get(const.URL_API_DISCOVERY_INFO) + result = await resp.json() + + assert result == { + "base_url": "", + "external_url": "", + "installation_type": "", + "internal_url": "", + "location_name": "", + "requires_api_password": True, + "uuid": "", + "version": "", + } diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 151f7972e1e..bc9cd5d2bd7 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -10,7 +10,7 @@ from tests.components.august.mocks import ( ) -async def test_create_doorbell(hass, aiohttp_client): +async def test_create_doorbell(hass, hass_client_no_auth): """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") @@ -28,7 +28,7 @@ async def test_create_doorbell(hass, aiohttp_client): "entity_picture" ] - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(url) assert resp.status == 200 body = await resp.text() diff --git a/tests/components/auth/test_mfa_setup_flow.py b/tests/components/auth/test_mfa_setup_flow.py index 3569d7d5233..edf45742dbd 100644 --- a/tests/components/auth/test_mfa_setup_flow.py +++ b/tests/components/auth/test_mfa_setup_flow.py @@ -67,7 +67,7 @@ async def test_ws_setup_depose_mfa(hass, hass_ws_client): assert flow["type"] == data_entry_flow.RESULT_TYPE_FORM assert flow["handler"] == "example_module" assert flow["step_id"] == "init" - assert flow["data_schema"][0] == {"type": "string", "name": "pin"} + assert flow["data_schema"][0] == {"type": "string", "name": "pin", "required": True} await client.send_json( { diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index b37e8dbf5d2..658ba802e8e 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -11,9 +11,10 @@ from homeassistant.components.awair.const import ( API_SPL_A, API_TEMP, API_VOC, - ATTR_UNIQUE_ID, DOMAIN, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, ) from homeassistant.const import ( ATTR_ICON, @@ -44,6 +45,10 @@ from .const import ( from tests.common import MockConfigEntry +SENSOR_TYPES_MAP = { + desc.key: desc for desc in (SENSOR_TYPE_SCORE, *SENSOR_TYPES, *SENSOR_TYPES_DUST) +} + async def setup_awair(hass, fixtures): """Add Awair devices to hass, using specified fixtures for data.""" @@ -80,7 +85,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", {ATTR_ICON: "mdi:blur"}, ) @@ -89,7 +94,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_temperature", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_TEMP][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_TEMP].unique_id_tag}", "21.8", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, "awair_index": 1.0}, ) @@ -98,7 +103,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_humidity", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_HUMID][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_HUMID].unique_id_tag}", "41.59", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, "awair_index": 0.0}, ) @@ -107,7 +112,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_carbon_dioxide", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_CO2][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_CO2].unique_id_tag}", "654.0", { ATTR_ICON: "mdi:cloud", @@ -120,7 +125,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_volatile_organic_compounds", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_VOC][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", { ATTR_ICON: "mdi:cloud", @@ -147,7 +152,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_pm10", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM10][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM10].unique_id_tag}", "14.3", { ATTR_ICON: "mdi:blur", @@ -176,7 +181,7 @@ async def test_awair_gen2_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "97", {ATTR_ICON: "mdi:blur"}, ) @@ -185,7 +190,7 @@ async def test_awair_gen2_sensors(hass): hass, registry, "sensor.living_room_pm2_5", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "2.0", { ATTR_ICON: "mdi:blur", @@ -210,7 +215,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "98", {ATTR_ICON: "mdi:blur"}, ) @@ -219,7 +224,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_pm2_5", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "1.0", { ATTR_ICON: "mdi:blur", @@ -232,7 +237,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_illuminance", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "441.7", {ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, ) @@ -252,7 +257,7 @@ async def test_awair_glow_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "93", {ATTR_ICON: "mdi:blur"}, ) @@ -272,7 +277,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "99", {ATTR_ICON: "mdi:blur"}, ) @@ -281,7 +286,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_sound_level", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SPL_A][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SPL_A].unique_id_tag}", "47.0", {ATTR_ICON: "mdi:ear-hearing", ATTR_UNIT_OF_MEASUREMENT: "dBa"}, ) @@ -290,7 +295,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_illuminance", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "804.9", {ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, ) @@ -325,7 +330,7 @@ async def test_awair_unavailable(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", {ATTR_ICON: "mdi:blur"}, ) @@ -338,7 +343,7 @@ async def test_awair_unavailable(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", STATE_UNAVAILABLE, {ATTR_ICON: "mdi:blur"}, ) diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 0791d002fed..0400b466e34 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -7,6 +7,8 @@ from datetime import timedelta from typing import Any from unittest.mock import MagicMock, patch +from aiohttp.client_exceptions import ClientResponseError + from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, STATE_UNAVAILABLE @@ -184,6 +186,16 @@ def patch_bond_action(): return patch("homeassistant.components.bond.Bond.action") +def patch_bond_action_returns_clientresponseerror(): + """Patch Bond API action endpoint to throw ClientResponseError.""" + return patch( + "homeassistant.components.bond.Bond.action", + side_effect=ClientResponseError( + request_info=None, history=None, code=405, message="Method Not Allowed" + ), + ) + + def patch_bond_device_properties(return_value=None): """Patch Bond API device properties endpoint.""" if return_value is None: diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index bd5994f5182..d24128617d2 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -4,9 +4,14 @@ from __future__ import annotations from datetime import timedelta from bond_api import Action, DeviceType, Direction +import pytest from homeassistant import core from homeassistant.components import fan +from homeassistant.components.bond.const import ( + DOMAIN as BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, +) from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_SPEED, @@ -19,6 +24,7 @@ from homeassistant.components.fan import ( SPEED_OFF, ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -26,6 +32,7 @@ from homeassistant.util import utcnow from .common import ( help_test_entity_available, patch_bond_action, + patch_bond_action_returns_clientresponseerror, patch_bond_device_state, setup_platform, ) @@ -254,6 +261,63 @@ async def test_turn_off_fan(hass: core.HomeAssistant): mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) +async def test_set_speed_belief_speed_zero(hass: core.HomeAssistant): + """Tests that set power belief service delegates to API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, + {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_set_speed_belief_speed_api_error(hass: core.HomeAssistant): + """Tests that set power belief service delegates to API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, + {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 100}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_set_speed_belief_speed_100(hass: core.HomeAssistant): + """Tests that set power belief service delegates to API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_FAN_SPEED_TRACKED_STATE, + {ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: 100}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_any_call("test-device-id", Action.set_power_state_belief(True)) + mock_action.assert_called_with("test-device-id", Action.set_speed_belief(3)) + + async def test_update_reports_fan_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports fan power is on.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 545feee21a5..e0ca9a05425 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -5,7 +5,12 @@ from bond_api import Action, DeviceType import pytest from homeassistant import core -from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.bond.const import ( + ATTR_POWER_STATE, + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, +) from homeassistant.components.bond.light import ( SERVICE_START_DECREASING_BRIGHTNESS, SERVICE_START_INCREASING_BRIGHTNESS, @@ -31,6 +36,7 @@ from homeassistant.util import utcnow from .common import ( help_test_entity_available, patch_bond_action, + patch_bond_action_returns_clientresponseerror, patch_bond_device_state, setup_platform, ) @@ -47,6 +53,15 @@ def light(name: str): } +def light_no_brightness(name: str): + """Create a light with a given name.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF], + } + + def ceiling_fan(name: str): """Create a ceiling fan (that has built-in light) with given name.""" return { @@ -106,6 +121,21 @@ def fireplace_with_light(name: str): } +def fireplace_with_light_supports_brightness(name: str): + """Create a fireplace with given name.""" + return { + "name": name, + "type": DeviceType.FIREPLACE, + "actions": [ + Action.TURN_ON, + Action.TURN_OFF, + Action.TURN_LIGHT_ON, + Action.TURN_LIGHT_OFF, + Action.SET_BRIGHTNESS, + ], + } + + def light_brightness_increase_decrease_only(name: str): """Create a light that can only increase or decrease brightness.""" return { @@ -254,6 +284,270 @@ async def test_no_trust_state(hass: core.HomeAssistant): assert device.attributes.get(ATTR_ASSUMED_STATE) is not True +async def test_light_set_brightness_belief_full(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_brightness_belief(brightness=100) + ) + + +async def test_light_set_brightness_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_fp_light_set_brightness_belief_full(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light_supports_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_brightness_belief(brightness=100) + ) + + +async def test_fp_light_set_brightness_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light_supports_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_set_brightness_belief_brightnes_not_supported( + hass: core.HomeAssistant, +): + """Tests that the set brightness belief function of a light that doesn't support setting brightness returns an error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_no_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_set_brightness_belief_zero(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_light_state_belief(False) + ) + + +async def test_fp_light_set_brightness_belief_zero(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light_supports_brightness("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_light_set_power_belief(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_light_state_belief(False) + ) + + +async def test_light_set_power_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_fp_light_set_power_belief(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light delegates to API.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_fp_light_set_power_belief_api_error(hass: core.HomeAssistant): + """Tests that the set brightness belief function of a light throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_POWER_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_fp_light_set_brightness_belief_brightnes_not_supported( + hass: core.HomeAssistant, +): + """Tests that the set brightness belief function of a fireplace light that doesn't support setting brightness returns an error.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + fireplace_with_light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_light_start_increasing_brightness(hass: core.HomeAssistant): """Tests a light that can only increase or decrease brightness delegates to API can start increasing brightness.""" await setup_platform( diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 94a9179d3a7..619eac69e71 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -2,10 +2,17 @@ from datetime import timedelta from bond_api import Action, DeviceType +import pytest from homeassistant import core +from homeassistant.components.bond.const import ( + ATTR_POWER_STATE, + DOMAIN as BOND_DOMAIN, + SERVICE_SET_POWER_TRACKED_STATE, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -13,6 +20,7 @@ from homeassistant.util import utcnow from .common import ( help_test_entity_available, patch_bond_action, + patch_bond_action_returns_clientresponseerror, patch_bond_device_state, setup_platform, ) @@ -76,6 +84,44 @@ async def test_turn_off_switch(hass: core.HomeAssistant): mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) +async def test_switch_set_power_belief(hass: core.HomeAssistant): + """Tests that the set power belief service delegates to API.""" + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_bond_action, patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_POWER_TRACKED_STATE, + {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action.set_power_state_belief(False) + ) + + +async def test_switch_set_power_belief_api_error(hass: core.HomeAssistant): + """Tests that the set power belief service throws HomeAssistantError in the event of an api error.""" + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) + + with pytest.raises( + HomeAssistantError + ), patch_bond_action_returns_clientresponseerror(), patch_bond_device_state(): + await hass.services.async_call( + BOND_DOMAIN, + SERVICE_SET_POWER_TRACKED_STATE, + {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_update_reports_switch_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the device is on.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 0e760b899c1..543d0438738 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -20,7 +20,7 @@ MOCK_SETTINGS = { "device": {"mac": "test-mac", "hostname": "test-host"}, } DISCOVERY_INFO = { - "host": "1.1.1.1", + "host": ["169.1.1.1", "1.1.1.1"], "port": 0, "hostname": "shc012345.local.", "type": "_http._tcp.local.", @@ -28,7 +28,7 @@ DISCOVERY_INFO = { } -async def test_form_user(hass): +async def test_form_user(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -92,7 +92,7 @@ async def test_form_user(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_get_info_connection_error(hass): +async def test_form_get_info_connection_error(hass, mock_zeroconf): """Test we handle connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -136,7 +136,7 @@ async def test_form_get_info_exception(hass): assert result2["errors"] == {"base": "unknown"} -async def test_form_pairing_error(hass): +async def test_form_pairing_error(hass, mock_zeroconf): """Test we handle pairing error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -178,7 +178,7 @@ async def test_form_pairing_error(hass): assert result3["errors"] == {"base": "pairing_failed"} -async def test_form_user_invalid_auth(hass): +async def test_form_user_invalid_auth(hass, mock_zeroconf): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -227,7 +227,7 @@ async def test_form_user_invalid_auth(hass): assert result3["errors"] == {"base": "invalid_auth"} -async def test_form_validate_connection_error(hass): +async def test_form_validate_connection_error(hass, mock_zeroconf): """Test we handle connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -276,7 +276,7 @@ async def test_form_validate_connection_error(hass): assert result3["errors"] == {"base": "cannot_connect"} -async def test_form_validate_session_error(hass): +async def test_form_validate_session_error(hass, mock_zeroconf): """Test we handle session error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -325,7 +325,7 @@ async def test_form_validate_session_error(hass): assert result3["errors"] == {"base": "session_error"} -async def test_form_validate_exception(hass): +async def test_form_validate_exception(hass, mock_zeroconf): """Test we handle exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -374,7 +374,7 @@ async def test_form_validate_exception(hass): assert result3["errors"] == {"base": "unknown"} -async def test_form_already_configured(hass): +async def test_form_already_configured(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( @@ -410,7 +410,7 @@ async def test_form_already_configured(hass): assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass): +async def test_zeroconf(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -479,7 +479,7 @@ async def test_zeroconf(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_already_configured(hass): +async def test_zeroconf_already_configured(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( @@ -512,7 +512,7 @@ async def test_zeroconf_already_configured(hass): assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf_cannot_connect(hass): +async def test_zeroconf_cannot_connect(hass, mock_zeroconf): """Test we get the form.""" with patch( "boschshcpy.session.SHCSession.mdns_info", side_effect=SHCConnectionError @@ -526,7 +526,29 @@ async def test_zeroconf_cannot_connect(hass): assert result["reason"] == "cannot_connect" -async def test_zeroconf_not_bosch_shc(hass): +async def test_zeroconf_link_local(hass, mock_zeroconf): + """Test we get the form.""" + DISCOVERY_INFO_LINK_LOCAL = { + "host": ["169.1.1.1"], + "port": 0, + "hostname": "shc012345.local.", + "type": "_http._tcp.local.", + "name": "Bosch SHC [test-mac]._http._tcp.local.", + } + + with patch( + "boschshcpy.session.SHCSession.mdns_info", side_effect=SHCConnectionError + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO_LINK_LOCAL, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_not_bosch_shc(hass, mock_zeroconf): """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -537,7 +559,7 @@ async def test_zeroconf_not_bosch_shc(hass): assert result["reason"] == "not_bosch_shc" -async def test_reauth(hass): +async def test_reauth(hass, mock_zeroconf): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) mock_config = MockConfigEntry( diff --git a/tests/components/broadlink/conftest.py b/tests/components/broadlink/conftest.py new file mode 100644 index 00000000000..0a9ee4813da --- /dev/null +++ b/tests/components/broadlink/conftest.py @@ -0,0 +1,11 @@ +"""Broadlink test helpers.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_heartbeat(): + """Mock broadlink heartbeat.""" + with patch("homeassistant.components.broadlink.heartbeat.blk.ping"): + yield diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index dbe5e74d891..9bf7512d238 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -9,8 +9,10 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) +from homeassistant.core import CoreState from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -22,6 +24,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_with_config(hass): """Test setup component with config.""" + assert hass.state is CoreState.running + config = { SENSOR_DOMAIN: [ {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT}, @@ -48,6 +52,8 @@ async def test_setup_with_config(hass): async def test_update_unique_id(hass): """Test updating a config entry without a unique_id.""" + assert hass.state is CoreState.running + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) entry.add_to_hass(hass) @@ -70,6 +76,8 @@ async def test_update_unique_id(hass): @patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) async def test_unload_config_entry(mock_now, hass): """Test unloading a config entry.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -87,6 +95,7 @@ async def test_unload_config_entry(mock_now, hass): return_value=timestamp, ): assert await async_setup_component(hass, DOMAIN, {}) is True + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -105,3 +114,35 @@ async def test_unload_config_entry(mock_now, hass): await hass.async_block_till_done() state = hass.states.get("sensor.cert_expiry_timestamp_example_com") assert state is None + + +async def test_delay_load_during_startup(hass): + """Test delayed loading of a config entry during startup.""" + hass.state = CoreState.not_running + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + assert hass.state is CoreState.not_running + assert entry.state is ConfigEntryState.LOADED + + state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + assert state is None + + timestamp = future_timestamp(100) + with patch( + "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + return_value=timestamp, + ): + await hass.async_start() + await hass.async_block_till_done() + + assert hass.state is CoreState.running + + state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + assert state.state == timestamp.isoformat() + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 099fe78ca39..a4456724270 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -5,8 +5,8 @@ import ssl from unittest.mock import patch from homeassistant.components.cert_expiry.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import CoreState from homeassistant.util.dt import utcnow from .const import HOST, PORT @@ -18,6 +18,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed @patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) async def test_async_setup_entry(mock_now, hass): """Test async_setup_entry.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -44,6 +46,8 @@ async def test_async_setup_entry(mock_now, hass): async def test_async_setup_entry_bad_cert(hass): """Test async_setup_entry with a bad/expired cert.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -65,38 +69,10 @@ async def test_async_setup_entry_bad_cert(hass): assert not state.attributes.get("is_valid") -async def test_async_setup_entry_host_unavailable(hass): - """Test async_setup_entry when host is unavailable.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - unique_id=f"{HOST}:{PORT}", - ) - - with patch( - "homeassistant.components.cert_expiry.helper.get_cert", - side_effect=socket.gaierror, - ): - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) is False - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY - - next_update = utcnow() + timedelta(seconds=45) - async_fire_time_changed(hass, next_update) - with patch( - "homeassistant.components.cert_expiry.helper.get_cert", - side_effect=socket.gaierror, - ): - await hass.async_block_till_done() - - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") - assert state is None - - async def test_update_sensor(hass): """Test async_update for sensor.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, @@ -139,6 +115,8 @@ async def test_update_sensor(hass): async def test_update_sensor_network_errors(hass): """Test async_update for sensor.""" + assert hass.state is CoreState.running + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 8613c6408fe..40809d2759c 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -12,7 +12,7 @@ async def mock_cloud(hass, config=None): assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) cloud_inst = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): - await cloud_inst.start() + await cloud_inst.initialize() def mock_cloud_prefs(hass, prefs={}): diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 75276a9f2e2..4bd5868db5f 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -12,7 +12,7 @@ from . import mock_cloud, mock_cloud_prefs @pytest.fixture(autouse=True) def mock_user_data(): """Mock os module.""" - with patch("hass_nabucasa.Cloud.write_user_info") as writer: + with patch("hass_nabucasa.Cloud._write_user_info") as writer: yield writer @@ -48,6 +48,8 @@ def mock_cloud_login(hass, mock_cloud_setup): }, "test", ) + with patch.object(hass.data[const.DOMAIN].auth, "async_check_token"): + yield @pytest.fixture diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 83c2a5aa2d1..60ef992dafb 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -28,6 +28,7 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs, cloud_stub): conf = alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) + await conf.async_initialize() assert not conf.should_expose("light.kitchen") entity_conf["should_expose"] = True @@ -50,6 +51,7 @@ async def test_alexa_config_report_state(hass, cloud_prefs, cloud_stub): conf = alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) + await conf.async_initialize() assert cloud_prefs.alexa_report_state is False assert conf.should_report_state is False @@ -131,9 +133,9 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to updating exposed entities.""" - alexa_config.AlexaConfig( + await alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ) + ).async_initialize() with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update_alexa_entity_config( @@ -166,9 +168,9 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" - alexa_config.AlexaConfig( + await alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] - ) + ).async_initialize() with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( @@ -218,9 +220,9 @@ async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): async def test_alexa_update_report_state(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to reporting state.""" - alexa_config.AlexaConfig( + await alexa_config.AlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ) + ).async_initialize() with patch( "homeassistant.components.cloud.alexa_config.AlexaConfig.async_sync_entities", @@ -244,3 +246,32 @@ def test_enabled_requires_valid_sub(hass, mock_expired_cloud_login, cloud_prefs) ) assert not config.enabled + + +async def test_alexa_handle_logout(hass, cloud_prefs, cloud_stub): + """Test Alexa config responds to logging out.""" + aconf = alexa_config.AlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + + await aconf.async_initialize() + + with patch( + "homeassistant.components.alexa.config.async_enable_proactive_mode", + return_value=Mock(), + ) as mock_enable: + await aconf.async_enable_proactive_mode() + + # This will trigger a prefs update when we logout. + await cloud_prefs.get_cloud_user() + + cloud_stub.is_logged_in = False + with patch.object( + cloud_stub.auth, + "async_check_token", + side_effect=AssertionError("Should not be called"), + ): + await cloud_prefs.async_set_username(None) + await hass.async_block_till_done() + + assert len(mock_enable.return_value.mock_calls) == 1 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index dfea8f80cee..c3890fb17ec 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -134,7 +134,7 @@ async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): assert await async_setup_component(hass, "cloud", {}) reqid = "5711642932632160983" @@ -149,7 +149,7 @@ async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): async def test_webhook_msg(hass, caplog): """Test webhook msg.""" - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): setup = await async_setup_component(hass, "cloud", {"cloud": {}}) assert setup cloud = hass.data["cloud"] @@ -261,7 +261,7 @@ async def test_set_username(hass): ) client = CloudClient(hass, prefs, None, {}, {}) client.cloud = MagicMock(is_logged_in=True, username="mock-username") - await client.logged_in() + await client.cloud_started() assert len(prefs.async_set_username.mock_calls) == 1 assert prefs.async_set_username.mock_calls[0][1][0] == "mock-username" @@ -279,7 +279,7 @@ async def test_login_recovers_bad_internet(hass, caplog): client._alexa_config = Mock( async_enable_proactive_mode=Mock(side_effect=aiohttp.ClientError) ) - await client.logged_in() + await client.cloud_started() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 1 assert "Unable to activate Alexa Report State" in caplog.text diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 64d50250259..a80ccaccd6c 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -31,6 +31,8 @@ async def test_google_update_report_state(mock_conf, hass, cloud_prefs): await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") + mock_conf._cloud.subscription_expired = False + with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch( "homeassistant.components.google_assistant.report_state.async_enable_report_state" ) as mock_report_state: @@ -41,6 +43,25 @@ async def test_google_update_report_state(mock_conf, hass, cloud_prefs): assert len(mock_report_state.mock_calls) == 1 +async def test_google_update_report_state_subscription_expired( + mock_conf, hass, cloud_prefs +): + """Test Google config not reporting state when subscription has expired.""" + await mock_conf.async_initialize() + await mock_conf.async_connect_agent_user("mock-user-id") + + assert mock_conf._cloud.subscription_expired + + with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch( + "homeassistant.components.google_assistant.report_state.async_enable_report_state" + ) as mock_report_state: + await cloud_prefs.async_update(google_report_state=True) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 0 + assert len(mock_report_state.mock_calls) == 0 + + async def test_sync_entities(mock_conf, hass, cloud_prefs): """Test sync devices.""" await mock_conf.async_initialize() @@ -112,7 +133,9 @@ async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): with patch.object( config, "async_schedule_google_sync_all" - ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + ) as mock_sync, patch.object(config, "async_sync_entities_all"), patch.object( + ga_helpers, "SYNC_DELAY", 0 + ): # Created entity hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, @@ -172,6 +195,7 @@ async def test_sync_google_when_started(hass, mock_cloud_login, cloud_prefs): with patch.object(config, "async_sync_entities_all") as mock_sync: await config.async_initialize() await config.async_connect_agent_user("mock-user-id") + await hass.async_block_till_done() assert len(mock_sync.mock_calls) == 1 @@ -242,3 +266,32 @@ async def test_setup_integration(hass, mock_conf, cloud_prefs): await cloud_prefs.async_update() await hass.async_block_till_done() assert "google_assistant" in hass.config.components + + +async def test_google_handle_logout(hass, cloud_prefs, mock_cloud_login): + """Test Google config responds to logging out.""" + gconf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + + await gconf.async_initialize() + + with patch( + "homeassistant.components.google_assistant.report_state.async_enable_report_state", + ) as mock_enable: + gconf.async_enable_report_state() + + assert len(mock_enable.mock_calls) == 1 + + # This will trigger a prefs update when we logout. + await cloud_prefs.get_cloud_user() + + with patch.object( + hass.data["cloud"].auth, + "async_check_token", + side_effect=AssertionError("Should not be called"), + ): + await cloud_prefs.async_set_username(None) + await hass.async_block_till_done() + + assert len(mock_enable.return_value.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 35d261d5603..4116e97be92 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -71,7 +71,7 @@ def setup_api_fixture(hass, aioclient_mock): @pytest.fixture(name="cloud_client") def cloud_client_fixture(hass, hass_client): """Fixture that can fetch from the cloud client.""" - with patch("hass_nabucasa.Cloud.write_user_info"): + with patch("hass_nabucasa.Cloud._write_user_info"): yield hass.loop.run_until_complete(hass_client()) @@ -394,59 +394,18 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): assert response["result"] == {"logged_in": False, "cloud": "disconnected"} -async def test_websocket_subscription_reconnect( +async def test_websocket_subscription_info( hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login ): """Test querying the status and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) client = await hass_ws_client(hass) - with patch( - "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" - ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: + with patch("hass_nabucasa.auth.CognitoAuth.async_renew_access_token") as mock_renew: await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() - assert response["result"] == {"provider": "stripe"} assert len(mock_renew.mock_calls) == 1 - assert len(mock_connect.mock_calls) == 1 - - -async def test_websocket_subscription_no_reconnect_if_connected( - hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login -): - """Test querying the status and not reconnecting because still expired.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) - hass.data[DOMAIN].iot.state = STATE_CONNECTED - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" - ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() - - assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 0 - assert len(mock_connect.mock_calls) == 0 - - -async def test_websocket_subscription_no_reconnect_if_expired( - hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login -): - """Test querying the status and not reconnecting because still expired.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" - ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() - - assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 1 - assert len(mock_connect.mock_calls) == 1 async def test_websocket_subscription_fail( @@ -466,7 +425,7 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): """Test querying the status.""" client = await hass_ws_client(hass) with patch( - "hass_nabucasa.Cloud.fetch_subscription_info", + "hass_nabucasa.cloud_api.async_subscription_info", return_value={"return": "value"}, ): await client.send_json({"id": 5, "type": "cloud/subscription"}) @@ -590,14 +549,8 @@ async def test_enabling_remote(hass, hass_ws_client, setup_api, mock_cloud_login assert len(mock_connect.mock_calls) == 1 - -async def test_disabling_remote(hass, hass_ws_client, setup_api, mock_cloud_login): - """Test we call right code to disable remote UI.""" - client = await hass_ws_client(hass) - cloud = hass.data[DOMAIN] - with patch("hass_nabucasa.remote.RemoteUI.disconnect") as mock_disconnect: - await client.send_json({"id": 5, "type": "cloud/remote/disconnect"}) + await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) response = await client.receive_json() assert response["success"] assert not cloud.client.remote_autostart diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 7202c8a0b39..f4ca4cbd75a 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -15,7 +15,7 @@ from homeassistant.setup import async_setup_component async def test_constructor_loads_info_from_config(hass): """Test non-dev mode loads info from SERVERS constant.""" - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): result = await async_setup_component( hass, "cloud", @@ -109,7 +109,7 @@ async def test_setup_existing_cloud_user(hass, hass_storage): """Test setup with API push default data.""" user = await hass.auth.async_create_system_user("Cloud test") hass_storage[STORAGE_KEY] = {"version": 1, "data": {"cloud_user": user.id}} - with patch("hass_nabucasa.Cloud.start"): + with patch("hass_nabucasa.Cloud.initialize"): result = await async_setup_component( hass, "cloud", @@ -163,7 +163,9 @@ async def test_remote_ui_url(hass, mock_cloud_fixture): with pytest.raises(cloud.CloudNotAvailable): cloud.async_remote_ui_url(hass) - await cl.client.prefs.async_update(remote_enabled=True) + with patch.object(cl.remote, "connect"): + await cl.client.prefs.async_update(remote_enabled=True) + await hass.async_block_till_done() # No instance domain with pytest.raises(cloud.CloudNotAvailable): diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 65ffd859f33..cc37788bc4c 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -28,7 +28,7 @@ async def test_cloud_system_health(hass, aioclient_mock): relayer="wss://cloud.bla.com/websocket_api", acme_directory_server="https://cert-server", is_logged_in=True, - remote=Mock(is_connected=False), + remote=Mock(is_connected=False, snitun_server="us-west-1"), expiration_date=now, is_connected=True, client=Mock( @@ -52,6 +52,7 @@ async def test_cloud_system_health(hass, aioclient_mock): "relayer_connected": True, "remote_enabled": True, "remote_connected": False, + "remote_server": "us-west-1", "alexa_enabled": True, "google_enabled": False, "can_reach_cert_server": "ok", diff --git a/tests/components/comfoconnect/test_sensor.py b/tests/components/comfoconnect/test_sensor.py index 3ae078cccef..578712aa1dc 100644 --- a/tests/components/comfoconnect/test_sensor.py +++ b/tests/components/comfoconnect/test_sensor.py @@ -54,40 +54,35 @@ async def test_sensors(hass, setup_sensor): """Test the sensors.""" state = hass.states.get("sensor.comfoairq_inside_humidity") assert state is not None - - assert state.name == "ComfoAirQ Inside Humidity" + assert state.name == "ComfoAirQ Inside humidity" assert state.attributes.get("unit_of_measurement") == "%" assert state.attributes.get("device_class") == "humidity" assert state.attributes.get("icon") is None state = hass.states.get("sensor.comfoairq_inside_temperature") assert state is not None - - assert state.name == "ComfoAirQ Inside Temperature" + assert state.name == "ComfoAirQ Inside temperature" assert state.attributes.get("unit_of_measurement") == "°C" assert state.attributes.get("device_class") == "temperature" assert state.attributes.get("icon") is None state = hass.states.get("sensor.comfoairq_supply_fan_duty") assert state is not None - - assert state.name == "ComfoAirQ Supply Fan Duty" + assert state.name == "ComfoAirQ Supply fan duty" assert state.attributes.get("unit_of_measurement") == "%" assert state.attributes.get("device_class") is None - assert state.attributes.get("icon") == "mdi:fan" + assert state.attributes.get("icon") == "mdi:fan-plus" state = hass.states.get("sensor.comfoairq_power_usage") assert state is not None - assert state.name == "ComfoAirQ Power usage" assert state.attributes.get("unit_of_measurement") == "W" assert state.attributes.get("device_class") == "power" assert state.attributes.get("icon") is None - state = hass.states.get("sensor.comfoairq_preheater_power_total") + state = hass.states.get("sensor.comfoairq_preheater_energy_total") assert state is not None - - assert state.name == "ComfoAirQ Preheater power total" + assert state.name == "ComfoAirQ Preheater energy total" assert state.attributes.get("unit_of_measurement") == "kWh" assert state.attributes.get("device_class") == "energy" assert state.attributes.get("icon") is None diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 2f15a167c92..06dd3434738 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -455,7 +455,7 @@ async def test_set_protection_value(hass, client): assert resp.status == 200 result = await resp.json() assert node.set_protection.called - assert result == {"message": "Protection setting succsessfully set"} + assert result == {"message": "Protection setting successfully set"} async def test_set_protection_value_failed(hass, client): diff --git a/tests/components/crownstone/__init__.py b/tests/components/crownstone/__init__.py new file mode 100644 index 00000000000..de960619d1d --- /dev/null +++ b/tests/components/crownstone/__init__.py @@ -0,0 +1 @@ +"""Tests for the Crownstone integration.""" diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py new file mode 100644 index 00000000000..05fde6109e7 --- /dev/null +++ b/tests/components/crownstone/test_config_flow.py @@ -0,0 +1,614 @@ +"""Tests for the Crownstone integration.""" +from __future__ import annotations + +from typing import Generator, Union +from unittest.mock import AsyncMock, MagicMock, patch + +from crownstone_cloud.cloud_models.spheres import Spheres +from crownstone_cloud.exceptions import ( + CrownstoneAuthenticationError, + CrownstoneUnknownError, +) +import pytest +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant import data_entry_flow +from homeassistant.components import usb +from homeassistant.components.crownstone.const import ( + CONF_USB_MANUAL_PATH, + CONF_USB_PATH, + CONF_USB_SPHERE, + CONF_USB_SPHERE_OPTION, + CONF_USE_USB_OPTION, + DOMAIN, + DONT_USE_USB, + MANUAL_PATH, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MockFixture = Generator[Union[MagicMock, AsyncMock], None, None] + + +@pytest.fixture(name="crownstone_setup") +def crownstone_setup() -> MockFixture: + """Mock Crownstone entry setup.""" + with patch( + "homeassistant.components.crownstone.async_setup_entry", return_value=True + ) as setup_mock: + yield setup_mock + + +@pytest.fixture(name="pyserial_comports") +def usb_comports() -> MockFixture: + """Mock pyserial comports.""" + with patch( + "serial.tools.list_ports.comports", + MagicMock(return_value=[get_mocked_com_port()]), + ) as comports_mock: + yield comports_mock + + +@pytest.fixture(name="pyserial_comports_none_types") +def usb_comports_none_types() -> MockFixture: + """Mock pyserial comports.""" + with patch( + "serial.tools.list_ports.comports", + MagicMock(return_value=[get_mocked_com_port_none_types()]), + ) as comports_mock: + yield comports_mock + + +@pytest.fixture(name="usb_path") +def usb_path() -> MockFixture: + """Mock usb serial path.""" + with patch( + "homeassistant.components.usb.get_serial_by_id", + return_value="/dev/serial/by-id/crownstone-usb", + ) as usb_path_mock: + yield usb_path_mock + + +def get_mocked_crownstone_entry_manager(mocked_cloud: MagicMock): + """Get a mocked CrownstoneEntryManager instance.""" + mocked_entry_manager = MagicMock() + mocked_entry_manager.async_setup = AsyncMock(return_value=True) + mocked_entry_manager.cloud = mocked_cloud + + return mocked_entry_manager + + +def get_mocked_crownstone_cloud(spheres: dict[str, MagicMock] | None = None): + """Return a mocked Crownstone Cloud instance.""" + mock_cloud = MagicMock() + mock_cloud.async_initialize = AsyncMock() + mock_cloud.cloud_data = Spheres(MagicMock(), "account_id") + mock_cloud.cloud_data.data = spheres + + return mock_cloud + + +def create_mocked_spheres(amount: int) -> dict[str, MagicMock]: + """Return a dict with mocked sphere instances.""" + spheres: dict[str, MagicMock] = {} + for i in range(amount): + spheres[f"sphere_id_{i}"] = MagicMock() + spheres[f"sphere_id_{i}"].name = f"sphere_name_{i}" + spheres[f"sphere_id_{i}"].cloud_id = f"sphere_id_{i}" + + return spheres + + +def get_mocked_com_port(): + """Mock of a serial port.""" + port = ListPortInfo("/dev/ttyUSB1234") + port.device = "/dev/ttyUSB1234" + port.serial_number = "1234567" + port.manufacturer = "crownstone" + port.description = "crownstone dongle - crownstone dongle" + port.vid = 1234 + port.pid = 5678 + + return port + + +def get_mocked_com_port_none_types(): + """Mock of a serial port with NoneTypes.""" + port = ListPortInfo("/dev/ttyUSB1234") + port.device = "/dev/ttyUSB1234" + port.serial_number = None + port.manufacturer = None + port.description = "crownstone dongle - crownstone dongle" + port.vid = None + port.pid = None + + return port + + +def create_mocked_entry_data_conf(email: str, password: str): + """Set a result for the entry data for comparison.""" + mock_data: dict[str, str | None] = {} + mock_data[CONF_EMAIL] = email + mock_data[CONF_PASSWORD] = password + + return mock_data + + +def create_mocked_entry_options_conf(usb_path: str | None, usb_sphere: str | None): + """Set a result for the entry options for comparison.""" + mock_options: dict[str, str | None] = {} + mock_options[CONF_USB_PATH] = usb_path + mock_options[CONF_USB_SPHERE] = usb_sphere + + return mock_options + + +async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): + """Patch Crownstone Cloud and start the flow.""" + mocked_login_input = { + CONF_EMAIL: "example@homeassistant.com", + CONF_PASSWORD: "homeassistantisawesome", + } + + with patch( + "homeassistant.components.crownstone.config_flow.CrownstoneCloud", + return_value=mocked_cloud, + ): + return await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=mocked_login_input + ) + + +async def start_options_flow( + hass: HomeAssistant, entry_id: str, mocked_manager: MagicMock +): + """Patch CrownstoneEntryManager and start the flow.""" + # set up integration + with patch( + "homeassistant.components.crownstone.CrownstoneEntryManager", + return_value=mocked_manager, + ): + await hass.config_entries.async_setup(entry_id) + + return await hass.config_entries.options.async_init(entry_id) + + +async def test_no_user_input(crownstone_setup: MockFixture, hass: HomeAssistant): + """Test the flow done in the correct way.""" + # test if a form is returned if no input is provided + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + # show the login form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert crownstone_setup.call_count == 0 + + +async def test_abort_if_configured(crownstone_setup: MockFixture, hass: HomeAssistant): + """Test flow with correct login input and abort if sphere already configured.""" + # create mock entry conf + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id", + ) + + # create mocked entry + MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ).add_to_hass(hass) + + result = await start_config_flow(hass, get_mocked_crownstone_cloud()) + + # test if we abort if we try to configure the same entry + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert crownstone_setup.call_count == 0 + + +async def test_authentication_errors( + crownstone_setup: MockFixture, hass: HomeAssistant +): + """Test flow with wrong auth errors.""" + cloud = get_mocked_crownstone_cloud() + # side effect: auth error login failed + cloud.async_initialize.side_effect = CrownstoneAuthenticationError( + exception_type="LOGIN_FAILED" + ) + + result = await start_config_flow(hass, cloud) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + # side effect: auth error account not verified + cloud.async_initialize.side_effect = CrownstoneAuthenticationError( + exception_type="LOGIN_FAILED_EMAIL_NOT_VERIFIED" + ) + + result = await start_config_flow(hass, cloud) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "account_not_verified"} + assert crownstone_setup.call_count == 0 + + +async def test_unknown_error(crownstone_setup: MockFixture, hass: HomeAssistant): + """Test flow with unknown error.""" + cloud = get_mocked_crownstone_cloud() + # side effect: unknown error + cloud.async_initialize.side_effect = CrownstoneUnknownError + + result = await start_config_flow(hass, cloud) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown_error"} + assert crownstone_setup.call_count == 0 + + +async def test_successful_login_no_usb( + crownstone_setup: MockFixture, hass: HomeAssistant +): + """Test a successful login without configuring a USB.""" + entry_data_without_usb = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + entry_options_without_usb = create_mocked_entry_options_conf( + usb_path=None, + usb_sphere=None, + ) + + result = await start_config_flow(hass, get_mocked_crownstone_cloud()) + # should show usb form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + + # don't setup USB dongle, create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: DONT_USE_USB} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == entry_data_without_usb + assert result["options"] == entry_options_without_usb + assert crownstone_setup.call_count == 1 + + +async def test_successful_login_with_usb( + crownstone_setup: MockFixture, + pyserial_comports_none_types: MockFixture, + usb_path: MockFixture, + hass: HomeAssistant, +): + """Test flow with correct login and usb configuration.""" + entry_data_with_usb = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + entry_options_with_usb = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id_1", + ) + + result = await start_config_flow( + hass, get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ) + # should show usb form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + assert pyserial_comports_none_types.call_count == 1 + + # create a mocked port which should be in + # the list returned from list_ports_as_str, from .helpers + port = get_mocked_com_port_none_types() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + + # select a port from the list + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: port_select} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_sphere_config" + assert pyserial_comports_none_types.call_count == 2 + assert usb_path.call_count == 1 + + # select a sphere + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_SPHERE: "sphere_name_1"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == entry_data_with_usb + assert result["options"] == entry_options_with_usb + assert crownstone_setup.call_count == 1 + + +async def test_successful_login_with_manual_usb_path( + crownstone_setup: MockFixture, pyserial_comports: MockFixture, hass: HomeAssistant +): + """Test flow with correct login and usb configuration.""" + entry_data_with_manual_usb = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + entry_options_with_manual_usb = create_mocked_entry_options_conf( + usb_path="/dev/crownstone-usb", + usb_sphere="sphere_id_0", + ) + + result = await start_config_flow( + hass, get_mocked_crownstone_cloud(create_mocked_spheres(1)) + ) + # should show usb form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 + + # select manual from the list + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: MANUAL_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_manual_config" + assert pyserial_comports.call_count == 2 + + # enter USB path + path = "/dev/crownstone-usb" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USB_MANUAL_PATH: path} + ) + + # since we only have 1 sphere here, test that it's automatically selected and + # creating entry without asking for user input + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == entry_data_with_manual_usb + assert result["options"] == entry_options_with_manual_usb + assert crownstone_setup.call_count == 1 + + +async def test_options_flow_setup_usb( + pyserial_comports: MockFixture, usb_path: MockFixture, hass: HomeAssistant +): + """Test options flow init.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path=None, + usb_sphere=None, + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + for schema_key in schema: + if schema_key == CONF_USE_USB_OPTION: + assert not schema_key.default() + + # USB is not set up, so this should not be in the options + assert CONF_USB_SPHERE_OPTION not in schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USE_USB_OPTION: True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 + + # create a mocked port which should be in + # the list returned from list_ports_as_str, from .helpers + port = get_mocked_com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + f"{hex(port.vid)[2:]:0>4}".upper(), + f"{hex(port.pid)[2:]:0>4}".upper(), + ) + + # select a port from the list + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: port_select} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_sphere_config" + assert pyserial_comports.call_count == 2 + assert usb_path.call_count == 1 + + # select a sphere + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_SPHERE: "sphere_name_1"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_1" + ) + + +async def test_options_flow_remove_usb(hass: HomeAssistant): + """Test selecting to set up an USB dongle.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id_0", + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(2)) + ), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + for schema_key in schema: + if schema_key == CONF_USE_USB_OPTION: + assert schema_key.default() + if schema_key == CONF_USB_SPHERE_OPTION: + assert schema_key.default() == "sphere_name_0" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USE_USB_OPTION: False, + CONF_USB_SPHERE_OPTION: "sphere_name_0", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path=None, usb_sphere=None + ) + + +async def test_options_flow_manual_usb_path( + pyserial_comports: MockFixture, hass: HomeAssistant +): + """Test flow with correct login and usb configuration.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path=None, + usb_sphere=None, + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(1)) + ), + ) + + 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_USE_USB_OPTION: True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_config" + assert pyserial_comports.call_count == 1 + + # select manual from the list + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_PATH: MANUAL_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "usb_manual_config" + assert pyserial_comports.call_count == 2 + + # enter USB path + path = "/dev/crownstone-usb" + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_USB_MANUAL_PATH: path} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path=path, usb_sphere="sphere_id_0" + ) + + +async def test_options_flow_change_usb_sphere(hass: HomeAssistant): + """Test changing the usb sphere in the options.""" + configured_entry_data = create_mocked_entry_data_conf( + email="example@homeassistant.com", + password="homeassistantisawesome", + ) + configured_entry_options = create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", + usb_sphere="sphere_id_0", + ) + + # create mocked entry + entry = MockConfigEntry( + domain=DOMAIN, + data=configured_entry_data, + options=configured_entry_options, + unique_id="account_id", + ) + entry.add_to_hass(hass) + + result = await start_options_flow( + hass, + entry.entry_id, + get_mocked_crownstone_entry_manager( + get_mocked_crownstone_cloud(create_mocked_spheres(3)) + ), + ) + + 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_USE_USB_OPTION: True, CONF_USB_SPHERE_OPTION: "sphere_name_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == create_mocked_entry_options_conf( + usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_2" + ) diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 624268d7ee3..91ea79f4aa7 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -3,13 +3,12 @@ import asyncio from unittest.mock import PropertyMock, patch -from aiohttp import ClientError -from aiohttp.web_exceptions import HTTPForbidden +from aiohttp import ClientError, web_exceptions import pytest from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY, @@ -84,8 +83,8 @@ async def test_abort_if_already_setup(hass, mock_daikin): "s_effect,reason", [ (asyncio.TimeoutError, "cannot_connect"), - (HTTPForbidden, "invalid_auth"), - (ClientError, "unknown"), + (ClientError, "cannot_connect"), + (web_exceptions.HTTPForbidden, "invalid_auth"), (Exception, "unknown"), ], ) @@ -103,6 +102,18 @@ async def test_device_abort(hass, mock_daikin, s_effect, reason): assert result["step_id"] == "user" +async def test_api_password_abort(hass): + """Test device abort.""" + result = await hass.config_entries.flow.async_init( + "daikin", + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_API_KEY: "aa", CONF_PASSWORD: "aa"}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "api_password"} + assert result["step_id"] == "user" + + @pytest.mark.parametrize( "source, data, unique_id", [ diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index 7b2c691bcae..8b92e94416a 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from unittest.mock import patch +from pydeconz.websocket import SIGNAL_CONNECTION_STATE, SIGNAL_DATA import pytest from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -19,10 +20,10 @@ def mock_deconz_websocket(): if data: mock.return_value.data = data - await pydeconz_gateway_session_handler(signal="data") + await pydeconz_gateway_session_handler(signal=SIGNAL_DATA) elif state: mock.return_value.state = state - await pydeconz_gateway_session_handler(signal="state") + await pydeconz_gateway_session_handler(signal=SIGNAL_CONNECTION_STATE) else: raise NotImplementedError diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 2a1b3c154f0..7f986ce4b81 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -181,6 +181,17 @@ async def test_allow_clip_sensor(hass, aioclient_mock): "config": {}, "uniqueid": "00:00:00:00:00:00:00:02-00", }, + "3": { + "config": {"on": True, "reachable": True}, + "etag": "fda064fca03f17389d0799d7cb1883ee", + "manufacturername": "Philips", + "modelid": "CLIPGenericFlag", + "name": "Clip Flag Boot Time", + "state": {"flag": True, "lastupdated": "2021-09-30T07:09:06.281"}, + "swversion": "1.0", + "type": "CLIPGenericFlag", + "uniqueid": "/sensors/3", + }, } } @@ -189,9 +200,10 @@ async def test_allow_clip_sensor(hass, aioclient_mock): hass, aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True} ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 assert hass.states.get("binary_sensor.presence_sensor").state == STATE_OFF assert hass.states.get("binary_sensor.clip_presence_sensor").state == STATE_OFF + assert hass.states.get("binary_sensor.clip_flag_boot_time").state == STATE_ON # Disallow clip sensors @@ -202,6 +214,7 @@ async def test_allow_clip_sensor(hass, aioclient_mock): assert len(hass.states.async_all()) == 1 assert not hass.states.get("binary_sensor.clip_presence_sensor") + assert not hass.states.get("binary_sensor.clip_flag_boot_time") # Allow clip sensors @@ -210,8 +223,9 @@ async def test_allow_clip_sensor(hass, aioclient_mock): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 assert hass.states.get("binary_sensor.clip_presence_sensor").state == STATE_OFF + assert hass.states.get("binary_sensor.clip_flag_boot_time").state == STATE_ON async def test_add_new_binary_sensor(hass, aioclient_mock, mock_deconz_websocket): diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 8a160b7ef19..4ee071f10d3 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -25,6 +25,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, @@ -163,7 +164,8 @@ async def test_gateway_setup(hass, aioclient_mock): assert forward_entry_setup.mock_calls[6][1] == (config_entry, LOCK_DOMAIN) assert forward_entry_setup.mock_calls[7][1] == (config_entry, SCENE_DOMAIN) assert forward_entry_setup.mock_calls[8][1] == (config_entry, SENSOR_DOMAIN) - assert forward_entry_setup.mock_calls[9][1] == (config_entry, SWITCH_DOMAIN) + assert forward_entry_setup.mock_calls[9][1] == (config_entry, SIREN_DOMAIN) + assert forward_entry_setup.mock_calls[10][1] == (config_entry, SWITCH_DOMAIN) async def test_gateway_retry(hass): @@ -267,14 +269,14 @@ async def test_reset_after_successful_setup(hass, aioclient_mock): async def test_get_gateway(hass): """Successful call.""" - with patch("pydeconz.DeconzSession.initialize", return_value=True): + with patch("pydeconz.DeconzSession.refresh_state", return_value=True): assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) async def test_get_gateway_fails_unauthorized(hass): """Failed call.""" with patch( - "pydeconz.DeconzSession.initialize", + "pydeconz.DeconzSession.refresh_state", side_effect=pydeconz.errors.Unauthorized, ), pytest.raises(AuthenticationRequired): assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False @@ -283,7 +285,7 @@ async def test_get_gateway_fails_unauthorized(hass): async def test_get_gateway_fails_cannot_connect(hass): """Failed call.""" with patch( - "pydeconz.DeconzSession.initialize", + "pydeconz.DeconzSession.refresh_state", side_effect=pydeconz.errors.RequestError, ), pytest.raises(CannotConnect): assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 814ec588b1e..e50ac41d63d 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -44,14 +44,16 @@ async def setup_entry(hass, entry): async def test_setup_entry_fails(hass): """Test setup entry fails if deCONZ is not available.""" - with patch("pydeconz.DeconzSession.initialize", side_effect=Exception): + with patch("pydeconz.DeconzSession.refresh_state", side_effect=Exception): await setup_deconz_integration(hass) assert not hass.data[DECONZ_DOMAIN] async def test_setup_entry_no_available_bridge(hass): """Test setup entry fails if deCONZ is not available.""" - with patch("pydeconz.DeconzSession.initialize", side_effect=asyncio.TimeoutError): + with patch( + "pydeconz.DeconzSession.refresh_state", side_effect=asyncio.TimeoutError + ): await setup_deconz_integration(hass) assert not hass.data[DECONZ_DOMAIN] diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 7f8bce24d80..624a1bec7ff 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er from homeassistant.util import dt @@ -197,6 +196,17 @@ async def test_allow_clip_sensors(hass, aioclient_mock): "config": {"reachable": True}, "uniqueid": "00:00:00:00:00:00:00:01-00", }, + "3": { + "config": {"on": True, "reachable": True}, + "etag": "a5ed309124d9b7a21ef29fc278f2625e", + "manufacturername": "Philips", + "modelid": "CLIPGenericStatus", + "name": "CLIP Flur", + "state": {"lastupdated": "2021-10-01T10:23:06.779", "status": 0}, + "swversion": "1.0", + "type": "CLIPGenericStatus", + "uniqueid": "/sensors/3", + }, } } with patch.dict(DECONZ_WEB_REQUEST, data): @@ -206,8 +216,9 @@ async def test_allow_clip_sensors(hass, aioclient_mock): options={CONF_ALLOW_CLIP_SENSOR: True}, ) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("sensor.clip_light_level_sensor").state == "999.8" + assert hass.states.get("sensor.clip_flur").state == "0" # Disallow clip sensors @@ -218,6 +229,7 @@ async def test_allow_clip_sensors(hass, aioclient_mock): assert len(hass.states.async_all()) == 2 assert not hass.states.get("sensor.clip_light_level_sensor") + assert not hass.states.get("sensor.clip_flur") # Allow clip sensors @@ -226,8 +238,9 @@ async def test_allow_clip_sensors(hass, aioclient_mock): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("sensor.clip_light_level_sensor").state == "999.8" + assert hass.states.get("sensor.clip_flur").state == "0" async def test_add_new_sensor(hass, aioclient_mock, mock_deconz_websocket): @@ -553,5 +566,4 @@ async def test_unsupported_sensor(hass, aioclient_mock): with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 1 - assert hass.states.get("sensor.name").state == STATE_UNKNOWN + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_siren.py b/tests/components/deconz/test_siren.py new file mode 100644 index 00000000000..c24e2087768 --- /dev/null +++ b/tests/components/deconz/test_siren.py @@ -0,0 +1,132 @@ +"""deCONZ switch platform tests.""" + +from unittest.mock import patch + +from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN +from homeassistant.components.siren import ATTR_DURATION, DOMAIN as SIREN_DOMAIN +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.helpers import entity_registry as er + +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) + + +async def test_sirens(hass, aioclient_mock, mock_deconz_websocket): + """Test that siren entities are created.""" + data = { + "lights": { + "1": { + "name": "Warning device", + "type": "Warning device", + "state": {"alert": "lselect", "reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + "2": { + "name": "Unsupported siren", + "type": "Not a siren", + "state": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == 2 + assert hass.states.get("siren.warning_device").state == STATE_ON + assert not hass.states.get("siren.unsupported_siren") + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"alert": None}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("siren.warning_device").state == STATE_OFF + + # Verify service calls + + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") + + # Service turn on siren + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "siren.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} + + # Service turn off siren + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "siren.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} + + # Service turn on siren with duration + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "siren.warning_device", ATTR_DURATION: 10}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"alert": "lselect", "ontime": 100} + + await hass.config_entries.async_unload(config_entry.entry_id) + + states = hass.states.async_all() + assert len(states) == 2 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_remove_legacy_siren_switch(hass, aioclient_mock): + """Test that switch platform cleans up legacy siren entities.""" + unique_id = "00:00:00:00:00:00:00:00-00" + + registry = er.async_get(hass) + switch_siren_entity = registry.async_get_or_create( + SWITCH_DOMAIN, DECONZ_DOMAIN, unique_id + ) + + assert switch_siren_entity + + data = { + "lights": { + "1": { + "name": "Warning device", + "type": "Warning device", + "state": {"alert": "lselect", "reachable": True}, + "uniqueid": unique_id, + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + await setup_deconz_integration(hass, aioclient_mock) + + assert not registry.async_get(switch_siren_entity.entity_id) diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index cffdf07ae2b..99dcbe1089a 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -107,76 +107,3 @@ async def test_power_plugs(hass, aioclient_mock, mock_deconz_websocket): await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - - -async def test_sirens(hass, aioclient_mock, mock_deconz_websocket): - """Test that siren entities are created.""" - data = { - "lights": { - "1": { - "name": "Warning device", - "type": "Warning device", - "state": {"alert": "lselect", "reachable": True}, - "uniqueid": "00:00:00:00:00:00:00:00-00", - }, - "2": { - "name": "Unsupported switch", - "type": "Not a switch", - "state": {"reachable": True}, - "uniqueid": "00:00:00:00:00:00:00:01-00", - }, - } - } - with patch.dict(DECONZ_WEB_REQUEST, data): - config_entry = await setup_deconz_integration(hass, aioclient_mock) - - assert len(hass.states.async_all()) == 2 - assert hass.states.get("switch.warning_device").state == STATE_ON - assert not hass.states.get("switch.unsupported_switch") - - event_changed_light = { - "t": "event", - "e": "changed", - "r": "lights", - "id": "1", - "state": {"alert": None}, - } - await mock_deconz_websocket(data=event_changed_light) - await hass.async_block_till_done() - - assert hass.states.get("switch.warning_device").state == STATE_OFF - - # Verify service calls - - mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") - - # Service turn on siren - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} - - # Service turn off siren - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} - - await hass.config_entries.async_unload(config_entry.entry_id) - - states = hass.states.async_all() - assert len(states) == 2 - for state in states: - assert state.state == STATE_UNAVAILABLE - - await hass.config_entries.async_remove(config_entry.entry_id) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index ec2d207a68b..1052eeeb164 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -29,6 +29,6 @@ def recorder_url_mock(): yield -async def test_setup(hass, mock_zeroconf): +async def test_setup(hass, mock_zeroconf, mock_get_source_ip): """Test setup.""" assert await async_setup_component(hass, "default_config", {"foo": "bar"}) diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 657836f9d16..311da47aac0 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from tests.components.devolo_home_control import configure_integration -async def test_setup_entry(hass: HomeAssistant): +async def test_setup_entry(hass: HomeAssistant, mock_zeroconf): """Test setup entry.""" entry = configure_integration(hass) with patch("homeassistant.components.devolo_home_control.HomeControl"): @@ -34,7 +34,7 @@ async def test_setup_entry_maintenance(hass: HomeAssistant): assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_gateway_offline(hass: HomeAssistant): +async def test_setup_gateway_offline(hass: HomeAssistant, mock_zeroconf): """Test setup entry fails on gateway offline.""" entry = configure_integration(hass) with patch( diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 0da383c758a..f00a0135e8d 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,7 +1,7 @@ """Test the DHCP discovery integration.""" import datetime import threading -from unittest.mock import patch +from unittest.mock import MagicMock, patch from scapy.error import Scapy_Exception from scapy.layers.dhcp import DHCP @@ -123,20 +123,39 @@ RAW_DHCP_REQUEST_WITHOUT_HOSTNAME = ( ) -async def test_dhcp_match_hostname_and_macaddress(hass): - """Test matching based on hostname and macaddress.""" +async def _async_get_handle_dhcp_packet(hass, integration_matchers): dhcp_watcher = dhcp.DHCPWatcher( hass, {}, - [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + integration_matchers, ) + handle_dhcp_packet = None + def _mock_sniffer(*args, **kwargs): + nonlocal handle_dhcp_packet + handle_dhcp_packet = kwargs["prn"] + return MagicMock() + + with patch("homeassistant.components.dhcp._verify_l2socket_setup",), patch( + "scapy.arch.common.compile_filter" + ), patch("scapy.sendrecv.AsyncSniffer", _mock_sniffer): + await dhcp_watcher.async_start() + + return handle_dhcp_packet + + +async def test_dhcp_match_hostname_and_macaddress(hass): + """Test matching based on hostname and macaddress.""" + integration_matchers = [ + {"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"} + ] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) # Ensure no change is ignored - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -152,18 +171,17 @@ async def test_dhcp_match_hostname_and_macaddress(hass): async def test_dhcp_renewal_match_hostname_and_macaddress(hass): """Test renewal matching based on hostname and macaddress.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, - {}, - [{"domain": "mock-domain", "hostname": "irobot-*", "macaddress": "501479*"}], - ) + integration_matchers = [ + {"domain": "mock-domain", "hostname": "irobot-*", "macaddress": "501479*"} + ] packet = Ether(RAW_DHCP_RENEWAL) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) # Ensure no change is ignored - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -179,14 +197,13 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass): async def test_dhcp_match_hostname(hass): """Test matching based on hostname only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "connect"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "connect"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -202,14 +219,13 @@ async def test_dhcp_match_hostname(hass): async def test_dhcp_match_macaddress(hass): """Test matching based on macaddress only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "macaddress": "B8B7F1*"}] - ) + integration_matchers = [{"domain": "mock-domain", "macaddress": "B8B7F1*"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -225,14 +241,13 @@ async def test_dhcp_match_macaddress(hass): async def test_dhcp_match_macaddress_without_hostname(hass): """Test matching based on macaddress only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "macaddress": "606BBD*"}] - ) + integration_matchers = [{"domain": "mock-domain", "macaddress": "606BBD*"}] packet = Ether(RAW_DHCP_REQUEST_WITHOUT_HOSTNAME) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" @@ -248,51 +263,46 @@ async def test_dhcp_match_macaddress_without_hostname(hass): async def test_dhcp_nomatch(hass): """Test not matching based on macaddress only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "macaddress": "ABC123*"}] - ) + integration_matchers = [{"domain": "mock-domain", "macaddress": "ABC123*"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_nomatch_hostname(hass): """Test not matching based on hostname only.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_nomatch_non_dhcp_packet(hass): """Test matching does not throw on a non-dhcp packet.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(b"") + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_nomatch_non_dhcp_request_packet(hass): """Test nothing happens with the wrong message-type.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -305,17 +315,16 @@ async def test_dhcp_nomatch_non_dhcp_request_packet(hass): ("hostname", b"connect"), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_invalid_hostname(hass): """Test we ignore invalid hostnames.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -328,17 +337,16 @@ async def test_dhcp_invalid_hostname(hass): ("hostname", "connect"), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_missing_hostname(hass): """Test we ignore missing hostnames.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -351,17 +359,16 @@ async def test_dhcp_missing_hostname(hass): ("hostname", None), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 async def test_dhcp_invalid_option(hass): """Test we ignore invalid hostname option.""" - dhcp_watcher = dhcp.DHCPWatcher( - hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}] - ) + integration_matchers = [{"domain": "mock-domain", "hostname": "nomatch*"}] packet = Ether(RAW_DHCP_REQUEST) @@ -374,8 +381,9 @@ async def test_dhcp_invalid_option(hass): ("hostname"), ] + handle_dhcp_packet = await _async_get_handle_dhcp_packet(hass, integration_matchers) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - dhcp_watcher.handle_dhcp_packet(packet) + handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -390,9 +398,9 @@ async def test_setup_and_stop(hass): ) await hass.async_block_till_done() - with patch("homeassistant.components.dhcp.AsyncSniffer.start") as start_call, patch( + with patch("scapy.sendrecv.AsyncSniffer.start") as start_call, patch( "homeassistant.components.dhcp._verify_l2socket_setup", - ), patch("homeassistant.components.dhcp.compile_filter",), patch( + ), patch("scapy.arch.common.compile_filter"), patch( "homeassistant.components.dhcp.DiscoverHosts.async_discover" ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -461,12 +469,10 @@ async def test_setup_fails_with_broken_libpcap(hass, caplog): ) await hass.async_block_till_done() - with patch("homeassistant.components.dhcp._verify_l2socket_setup",), patch( - "homeassistant.components.dhcp.compile_filter", + with patch("homeassistant.components.dhcp._verify_l2socket_setup"), patch( + "scapy.arch.common.compile_filter", side_effect=ImportError, - ) as compile_filter, patch( - "homeassistant.components.dhcp.AsyncSniffer", - ) as async_sniffer, patch( + ) as compile_filter, patch("scapy.sendrecv.AsyncSniffer") as async_sniffer, patch( "homeassistant.components.dhcp.DiscoverHosts.async_discover" ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/dialogflow/test_init.py b/tests/components/dialogflow/test_init.py index c2d0316245a..1f5b5bccfa9 100644 --- a/tests/components/dialogflow/test_init.py +++ b/tests/components/dialogflow/test_init.py @@ -34,7 +34,7 @@ async def calls(hass, fixture): @pytest.fixture -async def fixture(hass, aiohttp_client): +async def fixture(hass, hass_client_no_auth): """Initialize a Home Assistant server for testing this module.""" await async_setup_component(hass, dialogflow.DOMAIN, {"dialogflow": {}}) await async_setup_component( @@ -92,7 +92,7 @@ async def fixture(hass, aiohttp_client): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY webhook_id = result["result"].data["webhook_id"] - return await aiohttp_client(hass.http.app), webhook_id + return await hass_client_no_auth(), webhook_id class _Data: diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index 8be837bb16e..2b004135286 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -16,8 +16,10 @@ from tests.common import async_fire_time_changed, mock_coro SERVICE = "yamaha" SERVICE_COMPONENT = "media_player" -SERVICE_NO_PLATFORM = "netgear_router" -SERVICE_NO_PLATFORM_COMPONENT = "device_tracker" +# sabnzbd is the last no platform integration to be migrated +# drop these tests once it is migrated +SERVICE_NO_PLATFORM = "sabnzbd" +SERVICE_NO_PLATFORM_COMPONENT = "sabnzbd" SERVICE_INFO = {"key": "value"} # Can be anything UNKNOWN_SERVICE = "this_service_will_never_be_supported" diff --git a/tests/components/dlna_dmr/__init__.py b/tests/components/dlna_dmr/__init__.py new file mode 100644 index 00000000000..a1f4ccc2ba7 --- /dev/null +++ b/tests/components/dlna_dmr/__init__.py @@ -0,0 +1 @@ +"""Tests for the DLNA component.""" diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py new file mode 100644 index 00000000000..521a1c22fa5 --- /dev/null +++ b/tests/components/dlna_dmr/conftest.py @@ -0,0 +1,141 @@ +"""Fixtures for DLNA tests.""" +from __future__ import annotations + +from collections.abc import Iterable +from socket import AddressFamily # pylint: disable=no-name-in-module +from unittest.mock import Mock, create_autospec, patch, seal + +from async_upnp_client import UpnpDevice, UpnpFactory +import pytest + +from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN +from homeassistant.components.dlna_dmr.data import DlnaDmrData +from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE, CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_DEVICE_BASE_URL = "http://192.88.99.4" +MOCK_DEVICE_LOCATION = MOCK_DEVICE_BASE_URL + "/dmr_description.xml" +MOCK_DEVICE_NAME = "Test Renderer Device" +MOCK_DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaRenderer:1" +MOCK_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-58b275c52f1e" +MOCK_DEVICE_USN = f"{MOCK_DEVICE_UDN}::{MOCK_DEVICE_TYPE}" + +LOCAL_IP = "192.88.99.1" +EVENT_CALLBACK_URL = "http://192.88.99.1/notify" + +NEW_DEVICE_LOCATION = "http://192.88.99.7" + "/dmr_description.xml" + + +@pytest.fixture +def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: + """Mock the global data used by this component. + + This includes network clients and library object factories. Mocking it + prevents network use. + """ + domain_data = create_autospec(DlnaDmrData, instance=True) + domain_data.upnp_factory = create_autospec( + UpnpFactory, spec_set=True, instance=True + ) + + upnp_device = create_autospec(UpnpDevice, instance=True) + upnp_device.name = MOCK_DEVICE_NAME + upnp_device.udn = MOCK_DEVICE_UDN + upnp_device.device_url = MOCK_DEVICE_LOCATION + upnp_device.device_type = "urn:schemas-upnp-org:device:MediaRenderer:1" + upnp_device.available = True + upnp_device.parent_device = None + upnp_device.root_device = upnp_device + upnp_device.all_devices = [upnp_device] + seal(upnp_device) + domain_data.upnp_factory.async_create_device.return_value = upnp_device + + domain_data.unmigrated_config = {} + + with patch.dict(hass.data, {DLNA_DOMAIN: domain_data}): + yield domain_data + + # Make sure the event notifiers are released + assert ( + domain_data.async_get_event_notifier.await_count + == domain_data.async_release_event_notifier.await_count + ) + + +@pytest.fixture +def config_entry_mock() -> Iterable[MockConfigEntry]: + """Mock a config entry for this platform.""" + mock_entry = MockConfigEntry( + unique_id=MOCK_DEVICE_UDN, + domain=DLNA_DOMAIN, + data={ + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + }, + title=MOCK_DEVICE_NAME, + options={}, + ) + yield mock_entry + + +@pytest.fixture +def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: + """Mock the async_upnp_client DMR device, initially connected.""" + with patch( + "homeassistant.components.dlna_dmr.media_player.DmrDevice", autospec=True + ) as constructor: + device = constructor.return_value + device.on_event = None + device.profile_device = ( + domain_data_mock.upnp_factory.async_create_device.return_value + ) + device.media_image_url = "http://192.88.99.20:8200/AlbumArt/2624-17620.jpg" + device.udn = "device_udn" + device.manufacturer = "device_manufacturer" + device.model_name = "device_model_name" + device.name = "device_name" + + yield device + + # Make sure the device is disconnected + assert ( + device.async_subscribe_services.await_count + == device.async_unsubscribe_services.await_count + ) + + assert device.on_event is None + + +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture() -> Iterable[None]: + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(autouse=True) +def ssdp_scanner_mock() -> Iterable[Mock]: + """Mock the SSDP module.""" + with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: + reg_callback = mock_scanner.return_value.async_register_callback + reg_callback.return_value = Mock(return_value=None) + yield mock_scanner.return_value + assert ( + reg_callback.call_count == reg_callback.return_value.call_count + ), "Not all callbacks unregistered" + + +@pytest.fixture(autouse=True) +def async_get_local_ip_mock() -> Iterable[Mock]: + """Mock the async_get_local_ip utility function to prevent network access.""" + with patch( + "homeassistant.components.dlna_dmr.media_player.async_get_local_ip", + autospec=True, + ) as func: + func.return_value = AddressFamily.AF_INET, LOCAL_IP + yield func diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py new file mode 100644 index 00000000000..1bf93781be1 --- /dev/null +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -0,0 +1,624 @@ +"""Test the DLNA config flow.""" +from __future__ import annotations + +from unittest.mock import Mock + +from async_upnp_client import UpnpDevice, UpnpError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.dlna_dmr.const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_TYPE, + CONF_URL, +) +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_TYPE, + MOCK_DEVICE_UDN, + NEW_DEVICE_LOCATION, +) + +from tests.common import MockConfigEntry + +# Auto-use the domain_data_mock and dmr_device_mock fixtures for every test in this module +pytestmark = [ + pytest.mark.usefixtures("domain_data_mock"), + pytest.mark.usefixtures("dmr_device_mock"), +] + +WRONG_DEVICE_TYPE = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + +IMPORTED_DEVICE_NAME = "Imported DMR device" + +MOCK_CONFIG_IMPORT_DATA = { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, +} + +MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" +MOCK_ROOT_DEVICE_TYPE = "ROOT_DEVICE_TYPE" + +MOCK_DISCOVERY = { + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_ROOT_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, +} + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test user-init'd config flow with user entering a valid URL.""" + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_user_flow_uncontactable( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test user-init'd config flow with user entering an uncontactable URL.""" + # Device is not contactable + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "could_not_connect"} + assert result["step_id"] == "user" + + +async def test_user_flow_embedded_st( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test user-init'd flow for device with an embedded DMR.""" + # Device is the wrong type + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.udn = MOCK_ROOT_DEVICE_UDN + upnp_device.device_type = MOCK_ROOT_DEVICE_TYPE + upnp_device.name = "ROOT_DEVICE_NAME" + embedded_device = Mock(spec=UpnpDevice) + embedded_device.udn = MOCK_DEVICE_UDN + embedded_device.device_type = MOCK_DEVICE_TYPE + embedded_device.name = MOCK_DEVICE_NAME + upnp_device.all_devices.append(embedded_device) + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test user-init'd config flow with user entering a URL for the wrong device.""" + # Device has a sub device of the right type + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.device_type = WRONG_DEVICE_TYPE + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "not_dmr"} + assert result["step_id"] == "user" + + +async def test_import_flow_invalid(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test import flow of invalid YAML config.""" + # Missing CONF_URL + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "incomplete_config" + + # Device is not contactable + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN, CONF_URL: MOCK_DEVICE_LOCATION}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device is the wrong type + domain_data_mock.upnp_factory.async_create_device.side_effect = None + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.device_type = WRONG_DEVICE_TYPE + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN, CONF_URL: MOCK_DEVICE_LOCATION}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + + +async def test_import_flow_ssdp_discovered( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with a device also found via SSDP.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + await hass.async_block_till_done() + + assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: None, + CONF_CALLBACK_URL_OVERRIDE: None, + CONF_POLL_AVAILABILITY: False, + } + entry_id = result["result"].entry_id + + # The config entry should not be duplicated when dlna_dmr is restarted + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_direct_connect( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with a device *not found* via SSDP.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + await hass.async_block_till_done() + + assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: None, + CONF_CALLBACK_URL_OVERRIDE: None, + CONF_POLL_AVAILABILITY: True, + } + entry_id = result["result"].entry_id + + # The config entry should not be duplicated when dlna_dmr is restarted + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Remove the device to clean up all resources, completing its life cycle + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_options( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with options set.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_deferred_ssdp( + hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock +) -> None: + """Test YAML import of unavailable device later found via SSDP.""" + # Attempted import at hass start fails because device is unavailable + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [], + [], + [], + ] + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device becomes available then discovered via SSDP, import now occurs automatically + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + await hass.async_block_till_done() + + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: False, + } + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_deferred_user( + hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock +) -> None: + """Test YAML import of unavailable device later added by user.""" + # Attempted import at hass start fails because device is unavailable + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device becomes available then added by user, use all imported settings + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + await hass.async_block_till_done() + + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_ssdp_flow_success(hass: HomeAssistant) -> None: + """Test that SSDP discovery with an available device works.""" + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == {} + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_ssdp_flow_unavailable( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test that SSDP discovery with an unavailable device gives an error message. + + This may occur if the device is turned on, discovered, then turned off + before the user attempts to add it. + """ + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "confirm" + + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "could_not_connect"} + assert result["step_id"] == "confirm" + + +async def test_ssdp_flow_existing( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that SSDP discovery of existing config entry updates the URL.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION + + +async def test_ssdp_flow_upnp_udn( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that SSDP discovery ignores the root device's UDN.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ssdp.ATTR_UPNP_DEVICE_TYPE: "DIFFERENT_ROOT_DEVICE_TYPE", + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION + + +async def test_options_flow( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test config flow options.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry_mock.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + # Invalid URL for callback (can't be validated automatically by voluptuous) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CALLBACK_URL_OVERRIDE: "Bad url", + CONF_POLL_AVAILABILITY: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "invalid_url"} + + # Good data for all fields + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } diff --git a/tests/components/dlna_dmr/test_data.py b/tests/components/dlna_dmr/test_data.py new file mode 100644 index 00000000000..b4b9fcc76f2 --- /dev/null +++ b/tests/components/dlna_dmr/test_data.py @@ -0,0 +1,121 @@ +"""Tests for the DLNA DMR data module.""" +from __future__ import annotations + +from collections.abc import Iterable +from unittest.mock import ANY, Mock, patch + +from async_upnp_client import UpnpEventHandler +from async_upnp_client.aiohttp import AiohttpNotifyServer +import pytest + +from homeassistant.components.dlna_dmr.const import DOMAIN +from homeassistant.components.dlna_dmr.data import EventListenAddr, get_domain_data +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant + + +@pytest.fixture +def aiohttp_notify_servers_mock() -> Iterable[Mock]: + """Construct mock AiohttpNotifyServer on demand, eliminating network use. + + This fixture provides a list of the constructed servers. + """ + with patch( + "homeassistant.components.dlna_dmr.data.AiohttpNotifyServer" + ) as mock_constructor: + servers = [] + + def make_server(*_args, **_kwargs): + server = Mock(spec=AiohttpNotifyServer) + servers.append(server) + server.event_handler = Mock(spec=UpnpEventHandler) + return server + + mock_constructor.side_effect = make_server + + yield mock_constructor + + # Every server must be stopped if it was started + for server in servers: + assert server.start_server.call_count == server.stop_server.call_count + + +async def test_get_domain_data(hass: HomeAssistant) -> None: + """Test the get_domain_data function returns the same data every time.""" + assert DOMAIN not in hass.data + domain_data = get_domain_data(hass) + assert domain_data is not None + assert get_domain_data(hass) is domain_data + + +async def test_event_notifier( + hass: HomeAssistant, aiohttp_notify_servers_mock: Mock +) -> None: + """Test getting and releasing event notifiers.""" + domain_data = get_domain_data(hass) + + listen_addr = EventListenAddr(None, 0, None) + event_notifier = await domain_data.async_get_event_notifier(listen_addr, hass) + assert event_notifier is not None + + # Check that the parameters were passed through to the AiohttpNotifyServer + aiohttp_notify_servers_mock.assert_called_with( + requester=ANY, listen_port=0, listen_host=None, callback_url=None, loop=ANY + ) + + # Same address should give same notifier + listen_addr_2 = EventListenAddr(None, 0, None) + event_notifier_2 = await domain_data.async_get_event_notifier(listen_addr_2, hass) + assert event_notifier_2 is event_notifier + + # Different address should give different notifier + listen_addr_3 = EventListenAddr( + "192.88.99.4", 9999, "http://192.88.99.4:9999/notify" + ) + event_notifier_3 = await domain_data.async_get_event_notifier(listen_addr_3, hass) + assert event_notifier_3 is not None + assert event_notifier_3 is not event_notifier + + # Check that the parameters were passed through to the AiohttpNotifyServer + aiohttp_notify_servers_mock.assert_called_with( + requester=ANY, + listen_port=9999, + listen_host="192.88.99.4", + callback_url="http://192.88.99.4:9999/notify", + loop=ANY, + ) + + # There should be 2 notifiers total, one with 2 references, and a stop callback + assert set(domain_data.event_notifiers.keys()) == {listen_addr, listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 2, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + # Releasing notifiers should delete them when they have not more references + await domain_data.async_release_event_notifier(listen_addr) + assert set(domain_data.event_notifiers.keys()) == {listen_addr, listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 1, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + await domain_data.async_release_event_notifier(listen_addr) + assert set(domain_data.event_notifiers.keys()) == {listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 0, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + await domain_data.async_release_event_notifier(listen_addr_3) + assert set(domain_data.event_notifiers.keys()) == set() + assert domain_data.event_notifier_refs == {listen_addr: 0, listen_addr_3: 0} + assert domain_data.stop_listener_remove is None + + +async def test_cleanup_event_notifiers(hass: HomeAssistant) -> None: + """Test cleanup function clears all event notifiers.""" + domain_data = get_domain_data(hass) + await domain_data.async_get_event_notifier(EventListenAddr(None, 0, None), hass) + await domain_data.async_get_event_notifier( + EventListenAddr(None, 0, "different"), hass + ) + + await domain_data.async_cleanup_event_notifiers(Event(EVENT_HOMEASSISTANT_STOP)) + + assert not domain_data.event_notifiers + assert not domain_data.event_notifier_refs diff --git a/tests/components/dlna_dmr/test_init.py b/tests/components/dlna_dmr/test_init.py new file mode 100644 index 00000000000..91aec7310ab --- /dev/null +++ b/tests/components/dlna_dmr/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the DLNA DMR __init__ module.""" + +from unittest.mock import Mock + +from async_upnp_client import UpnpError + +from homeassistant.components.dlna_dmr.const import ( + CONF_LISTEN_PORT, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from .conftest import MOCK_DEVICE_LOCATION + + +async def test_import_flow_started(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test import flow of YAML config is started if there's config data.""" + mock_config: ConfigType = { + MEDIA_PLAYER_DOMAIN: [ + { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_LISTEN_PORT: 1234, + }, + { + CONF_PLATFORM: "other_domain", + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: "another device", + }, + ] + } + + # Device is not available yet + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + # Run the setup + await async_setup_component(hass, DLNA_DOMAIN, mock_config) + await hass.async_block_till_done() + + # Check config_flow has completed + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + # Check device contact attempt was made + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check the device is added to the unmigrated configs + assert domain_data_mock.unmigrated_config == { + MOCK_DEVICE_LOCATION: { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_LISTEN_PORT: 1234, + } + } diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py new file mode 100644 index 00000000000..4c27de1be67 --- /dev/null +++ b/tests/components/dlna_dmr/test_media_player.py @@ -0,0 +1,1377 @@ +"""Tests for the DLNA DMR media_player module.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Mapping +from datetime import timedelta +from types import MappingProxyType +from typing import Any +from unittest.mock import ANY, DEFAULT, Mock, patch + +from async_upnp_client.exceptions import UpnpConnectionError, UpnpError +from async_upnp_client.profiles.dlna import TransportState +import pytest + +from homeassistant import const as ha_const +from homeassistant.components import ssdp +from homeassistant.components.dlna_dmr import media_player +from homeassistant.components.dlna_dmr.const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.components.dlna_dmr.data import EventListenAddr +from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const +from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import async_get as async_get_dr +from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get as async_get_er, +) +from homeassistant.setup import async_setup_component + +from .conftest import ( + LOCAL_IP, + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_UDN, + MOCK_DEVICE_USN, + NEW_DEVICE_LOCATION, +) + +from tests.common import MockConfigEntry + +# Auto-use the domain_data_mock fixture for every test in this module +pytestmark = pytest.mark.usefixtures("domain_data_mock") + + +async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) -> str: + """Set up a mock DlnaDmrEntity with the given configuration.""" + mock_entry.add_to_hass(hass) + assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True + await hass.async_block_till_done() + + entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) + assert len(entries) == 1 + entity_id = entries[0].entity_id + + return entity_id + + +@pytest.fixture +async def mock_entity_id( + hass: HomeAssistant, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> AsyncIterable[str]: + """Fixture to set up a mock DlnaDmrEntity in a connected state. + + Yields the entity ID. Cleans up the entity after the test is complete. + """ + entity_id = await setup_mock_component(hass, config_entry_mock) + + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + yield entity_id + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +@pytest.fixture +async def mock_disconnected_entity_id( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> AsyncIterable[str]: + """Fixture to set up a mock DlnaDmrEntity in a disconnected state. + + Yields the entity ID. Cleans up the entity after the test is complete. + """ + # Cause the connection attempt to fail + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + + entity_id = await setup_mock_component(hass, config_entry_mock) + + assert dmr_device_mock.async_subscribe_services.await_count == 0 + + yield entity_id + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_setup_entry_no_options( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test async_setup_entry creates a DlnaDmrEntity when no options are set. + + Check that the device is constructed properly as part of the test. + """ + config_entry_mock.options = MappingProxyType({}) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are acquired + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has a connected DmrDevice + assert mock_state.state == media_player.STATE_IDLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update retrieves state from the device, but does not ping, + # because poll_availability is False + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=False) + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_setup_entry_with_options( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test setting options leads to a DlnaDmrEntity with custom event_handler. + + Check that the device is constructed properly as part of the test. + """ + config_entry_mock.options = MappingProxyType( + { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://192.88.99.10/events", + CONF_POLL_AVAILABILITY: True, + } + ) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are acquired with the configured port and callback URL + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 2222, "http://192.88.99.10/events"), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has a connected DmrDevice + assert mock_state.state == media_player.STATE_IDLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update retrieves state from the device, and also pings it, + # because poll_availability is True + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=True) + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_event_subscribe_failure( + hass: HomeAssistant, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test _device_connect aborts when async_subscribe_services fails.""" + dmr_device_mock.async_subscribe_services.side_effect = UpnpError + + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Device should not be connected + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Device should not be unsubscribed + dmr_device_mock.async_unsubscribe_services.assert_not_awaited() + + # Clear mocks for tear down checks + dmr_device_mock.async_subscribe_services.reset_mock() + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_available_device( + hass: HomeAssistant, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test a DlnaDmrEntity with a connected DmrDevice.""" + # Check hass device information is filled in + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + # Device properties are set in dmr_device_mock before the entity gets constructed + assert device.manufacturer == "device_manufacturer" + assert device.model == "device_model_name" + assert device.name == "device_name" + + # Check entity state gets updated when device changes state + for (dev_state, ent_state) in [ + (None, ha_const.STATE_ON), + (TransportState.STOPPED, ha_const.STATE_IDLE), + (TransportState.PLAYING, ha_const.STATE_PLAYING), + (TransportState.TRANSITIONING, ha_const.STATE_PLAYING), + (TransportState.PAUSED_PLAYBACK, ha_const.STATE_PAUSED), + (TransportState.PAUSED_RECORDING, ha_const.STATE_PAUSED), + (TransportState.RECORDING, ha_const.STATE_IDLE), + (TransportState.NO_MEDIA_PRESENT, ha_const.STATE_IDLE), + (TransportState.VENDOR_DEFINED, ha_const.STATE_UNKNOWN), + ]: + dmr_device_mock.profile_device.available = True + dmr_device_mock.transport_state = dev_state + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.state == ent_state + + dmr_device_mock.profile_device.available = False + dmr_device_mock.transport_state = TransportState.PLAYING + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.state == ha_const.STATE_UNAVAILABLE + + dmr_device_mock.profile_device.available = True + await async_update_entity(hass, mock_entity_id) + + # Check attributes come directly from the device + async def get_attrs() -> Mapping[str, Any]: + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + attrs = entity_state.attributes + assert attrs is not None + return attrs + + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level + assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted + assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration + assert attrs[mp_const.ATTR_MEDIA_POSITION] is dmr_device_mock.media_position + assert ( + attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT] + is dmr_device_mock.media_position_updated_at + ) + assert attrs[mp_const.ATTR_MEDIA_CONTENT_ID] is dmr_device_mock.current_track_uri + assert attrs[mp_const.ATTR_MEDIA_ARTIST] is dmr_device_mock.media_artist + assert attrs[mp_const.ATTR_MEDIA_ALBUM_NAME] is dmr_device_mock.media_album_name + assert attrs[mp_const.ATTR_MEDIA_ALBUM_ARTIST] is dmr_device_mock.media_album_artist + assert attrs[mp_const.ATTR_MEDIA_TRACK] is dmr_device_mock.media_track_number + assert attrs[mp_const.ATTR_MEDIA_SERIES_TITLE] is dmr_device_mock.media_series_title + assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number + assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number + assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name + # Entity picture is cached, won't correspond to remote image + assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) + # media_title depends on what is available + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title + dmr_device_mock.media_program_title = None + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + # media_content_type is mapped from UPnP class to MediaPlayer type + dmr_device_mock.media_class = "object.item.audioItem.musicTrack" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC + dmr_device_mock.media_class = "object.item.videoItem.movie" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE + dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW + # media_season & media_episode have a special case + dmr_device_mock.media_season_number = "0" + dmr_device_mock.media_episode_number = "123" + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1" + assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23" + dmr_device_mock.media_season_number = "0" + dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed + attrs = await get_attrs() + assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0" + assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23" + + # Check supported feature flags, one at a time. + # tuple(async_upnp_client feature check property, HA feature flag) + FEATURE_FLAGS: list[tuple[str, int]] = [ + ("has_volume_level", mp_const.SUPPORT_VOLUME_SET), + ("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE), + ("can_play", mp_const.SUPPORT_PLAY), + ("can_pause", mp_const.SUPPORT_PAUSE), + ("can_stop", mp_const.SUPPORT_STOP), + ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), + ("can_next", mp_const.SUPPORT_NEXT_TRACK), + ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), + ("can_seek_rel_time", mp_const.SUPPORT_SEEK), + ] + # Clear all feature properties + for feat_prop, _ in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, False) + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + # Test the properties cumulatively + expected_features = 0 + for feat_prop, flag in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, True) + expected_features |= flag + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert ( + entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] + == expected_features + ) + + # Check interface methods interact directly with the device + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + dmr_device_mock.async_set_volume_level.assert_awaited_once_with(0.80) + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + dmr_device_mock.async_mute_volume.assert_awaited_once_with(True) + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_pause.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_pause.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_stop.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_next.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_previous.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SEEK_POSITION: 33}, + blocking=True, + ) + dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33)) + + # play_media performs a few calls to the device for setup and play + # Start from stopped, and device can stop too + dmr_device_mock.can_stop = True + dmr_device_mock.transport_state = TransportState.STOPPED + dmr_device_mock.async_stop.reset_mock() + dmr_device_mock.async_set_transport_uri.reset_mock() + dmr_device_mock.async_wait_for_can_play.reset_mock() + dmr_device_mock.async_play.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + blocking=True, + ) + dmr_device_mock.async_stop.assert_awaited_once_with() + dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant" + ) + dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_play.assert_awaited_once_with() + + # play_media again, while the device is already playing and can't stop + dmr_device_mock.can_stop = False + dmr_device_mock.transport_state = TransportState.PLAYING + dmr_device_mock.async_stop.reset_mock() + dmr_device_mock.async_set_transport_uri.reset_mock() + dmr_device_mock.async_wait_for_can_play.reset_mock() + dmr_device_mock.async_play.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + blocking=True, + ) + dmr_device_mock.async_stop.assert_not_awaited() + dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant" + ) + dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_play.assert_not_awaited() + + +async def test_unavailable_device( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, +) -> None: + """Test a DlnaDmrEntity with out a connected DmrDevice.""" + # Cause connection attempts to fail + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + + with patch( + "homeassistant.components.dlna_dmr.media_player.DmrDevice", autospec=True + ) as dmr_device_constructor_mock: + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device is not created + dmr_device_constructor_mock.assert_not_called() + + # Check attempt was made to create a device from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are not acquired + domain_data_mock.async_get_event_notifier.assert_not_called() + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has no connected DmrDevice + assert mock_state.state == ha_const.STATE_UNAVAILABLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update does not attempt to contact the device because + # poll_availability is False + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_not_called() + + # Now set poll_availability = True and expect construction attempt + hass.config_entries.async_update_entry( + config_entry_mock, options={CONF_POLL_AVAILABILITY: True} + ) + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check attributes are unavailable + attrs = mock_state.attributes + for attr in ATTR_TO_PROPERTY: + assert attr not in attrs + + assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME + assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + + # Check service calls do nothing + SERVICES: list[tuple[str, dict]] = [ + (ha_const.SERVICE_VOLUME_SET, {mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}), + (ha_const.SERVICE_VOLUME_MUTE, {mp_const.ATTR_MEDIA_VOLUME_MUTED: True}), + (ha_const.SERVICE_MEDIA_PAUSE, {}), + (ha_const.SERVICE_MEDIA_PLAY, {}), + (ha_const.SERVICE_MEDIA_STOP, {}), + (ha_const.SERVICE_MEDIA_NEXT_TRACK, {}), + (ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, {}), + (ha_const.SERVICE_MEDIA_SEEK, {mp_const.ATTR_MEDIA_SEEK_POSITION: 33}), + ( + mp_const.SERVICE_PLAY_MEDIA, + { + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + ), + ] + for service, data in SERVICES: + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: mock_entity_id, **data}, + blocking=True, + ) + + # Check hass device information has not been filled in yet + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is None + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Check event notifiers are not released + domain_data_mock.async_release_event_notifier.assert_not_called() + + # Confirm the entity is still unavailable + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_become_available( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test a device becoming available after the entity is constructed.""" + # Cause connection attempts to fail before adding entity + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Check hass device information has not been filled in yet + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is None + + # Mock device is now available. + domain_data_mock.upnp_factory.async_create_device.side_effect = None + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Send an SSDP notification from the now alive device + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + NEW_DEVICE_LOCATION + ) + # Check event notifiers are acquired + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Quick check of the state to verify the entity has a connected DmrDevice + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + # Check hass device information is now filled in + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert device.manufacturer == "device_manufacturer" + assert device.model == "device_model_name" + assert device.name == "device_name" + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + # Confirm SSDP notifications unregistered + assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_alive_but_gone( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, +) -> None: + """Test a device sending an SSDP alive announcement, but not being connectable.""" + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + # Send an SSDP notification from the still missing device + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Device should still be unavailable + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_multiple_ssdp_alive( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, +) -> None: + """Test multiple SSDP alive notifications is ok, only connects to device once.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Contacting the device takes long enough that 2 simultaneous attempts could be made + async def create_device_delayed(_location): + """Delay before continuing with async_create_device. + + This gives a chance for parallel calls to `_device_connect` to occur. + """ + await asyncio.sleep(0.1) + return DEFAULT + + domain_data_mock.upnp_factory.async_create_device.side_effect = ( + create_device_delayed + ) + + # Send two SSDP notifications with the new device URL + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Check device is contacted exactly once + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + NEW_DEVICE_LOCATION + ) + + # Device should be available + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == media_player.STATE_IDLE + + +async def test_ssdp_byebye( + hass: HomeAssistant, + ssdp_scanner_mock: Mock, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device is disconnected when byebye is received.""" + # First byebye will cause a disconnect + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:byebye", + }, + ssdp.SsdpChange.BYEBYE, + ) + + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + + # Device should be gone + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == media_player.STATE_IDLE + + # Second byebye will do nothing + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:byebye", + }, + ssdp.SsdpChange.BYEBYE, + ) + + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + + +async def test_ssdp_update_seen_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device does not reconnect when it gets ssdp:update with next bootid.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Send SSDP update with next boot ID + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Device was not reconnected, even with a new boot ID + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send SSDP update with same next boot ID, again + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Nothing should change + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send SSDP update with bad next boot ID + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "7c848375-a106-4bd1-ac3c-8e50427c8e4f", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Nothing should change + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send a new SSDP alive with the new boot ID, device should not reconnect + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "2", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + +async def test_ssdp_update_missed_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device disconnects when it gets ssdp:update bootid it wasn't expecting.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Send SSDP update with skipped boot ID (not previously seen) + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "3", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Device should not reconnect yet + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send a new SSDP alive with the new boot ID, device should reconnect + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "3", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + +async def test_ssdp_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 1 + assert dmr_device_mock.async_unsubscribe_services.call_count == 0 + + # Send SSDP alive with same boot ID, nothing should happen + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 1 + assert dmr_device_mock.async_unsubscribe_services.call_count == 0 + + # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "2", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 2 + assert dmr_device_mock.async_unsubscribe_services.call_count == 1 + + +async def test_become_unavailable( + hass: HomeAssistant, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test a device becoming unavailable.""" + # Check async_update currently works + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=False) + + # Now break the network connection and try to contact the device + dmr_device_mock.async_set_volume_level.side_effect = UpnpConnectionError + dmr_device_mock.async_update.reset_mock() + + # Interface service calls should flag that the device is unavailable, but + # not disconnect it immediately + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # With a working connection, the state should be restored + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_any_call(do_ping=True) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # Break the service again, and the connection too. An update will cause the + # device to be disconnected + dmr_device_mock.async_update.reset_mock() + dmr_device_mock.async_update.side_effect = UpnpConnectionError + + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=True) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_poll_availability( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test device becomes available and noticed via poll_availability.""" + # Start with a disconnected device and poll_availability=True + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + config_entry_mock.options = MappingProxyType( + { + CONF_POLL_AVAILABILITY: True, + } + ) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Check that an update will poll the device for availability + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Check that an update will notice the device and connect to it + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # Clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_disappearing_device( + hass: HomeAssistant, + mock_disconnected_entity_id: str, +) -> None: + """Test attribute update or service call as device disappears. + + Normally HA will check if the entity is available before updating attributes + or calling a service, but it's possible for the device to go offline in + between the check and the method call. Here we test by accessing the entity + directly to skip the availability check. + """ + # Retrieve entity directly. + entity: media_player.DlnaDmrEntity = hass.data[MP_DOMAIN].get_entity( + mock_disconnected_entity_id + ) + + # Test attribute access + for attr in ATTR_TO_PROPERTY: + value = getattr(entity, attr) + assert value is None + + # media_image_url is normally hidden by entity_picture, but we want a direct check + assert entity.media_image_url is None + + # Test service calls + await entity.async_set_volume_level(0.1) + await entity.async_mute_volume(True) + await entity.async_media_pause() + await entity.async_media_play() + await entity.async_media_stop() + await entity.async_media_seek(22.0) + await entity.async_play_media("", "") + await entity.async_media_previous_track() + await entity.async_media_next_track() + + +async def test_resubscribe_failure( + hass: HomeAssistant, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test failure to resubscribe to events notifications causes an update ping.""" + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=False) + dmr_device_mock.async_update.reset_mock() + + on_event = dmr_device_mock.on_event + on_event(None, []) + await hass.async_block_till_done() + + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=True) + + +async def test_config_update_listen_port( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_LISTEN_PORT.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_LISTEN_PORT: 1234, + }, + ) + await hass.async_block_till_done() + + # A new event listener with the changed port will be used + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_with( + EventListenAddr(LOCAL_IP, 1234, None), hass + ) + + # Device will be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + +async def test_config_update_connect_failure( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gracefully handles connect failure after config change.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_LISTEN_PORT: 1234, + }, + ) + await hass.async_block_till_done() + + # Old event listener was released, new event listener was not created + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_once() + + # There was an attempt to connect to the device + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check that its no longer connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_config_update_callback_url( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_CALLBACK_URL_OVERRIDE.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_CALLBACK_URL_OVERRIDE: "http://www.example.net/notify", + }, + ) + await hass.async_block_till_done() + + # A new event listener with the changed callback URL will be used + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_with( + EventListenAddr(LOCAL_IP, 0, "http://www.example.net/notify"), hass + ) + + # Device will be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + +async def test_config_update_poll_availability( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_POLL_AVAILABILITY.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Updates of the device will not ping it yet + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=False) + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_POLL_AVAILABILITY: True, + }, + ) + await hass.async_block_till_done() + + # Event listeners will not change + domain_data_mock.async_release_event_notifier.assert_not_awaited() + domain_data_mock.async_get_event_notifier.assert_awaited_once() + + # Device will not be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_not_awaited() + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Updates of the device will now ping it + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=True) + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index a56e28cb3ef..97478155483 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -1,8 +1,10 @@ """The tests for Efergy sensor platform.""" +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker token = "9p6QGJ7dpZfO3fqPTBk1fyEmjV1cGoLT" multi_sensor_token = "9r6QGF7dpZfO3fqPTBl1fyRmjV1cGoLT" @@ -28,38 +30,40 @@ MULTI_SENSOR_CONFIG = { } -def mock_responses(mock): +def mock_responses(aioclient_mock: AiohttpClientMocker): """Mock responses for Efergy.""" base_url = "https://engage.efergy.com/mobile_proxy/" - mock.get( + aioclient_mock.get( f"{base_url}getInstant?token={token}", - text=load_fixture("efergy_instant.json"), + text=load_fixture("efergy/efergy_instant.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getEnergy?token={token}&offset=300&period=day", - text=load_fixture("efergy_energy.json"), + text=load_fixture("efergy/efergy_energy.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getBudget?token={token}", - text=load_fixture("efergy_budget.json"), + text=load_fixture("efergy/efergy_budget.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getCost?token={token}&offset=300&period=day", - text=load_fixture("efergy_cost.json"), + text=load_fixture("efergy/efergy_cost.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getCurrentValuesSummary?token={token}", - text=load_fixture("efergy_current_values_single.json"), + text=load_fixture("efergy/efergy_current_values_single.json"), ) - mock.get( + aioclient_mock.get( f"{base_url}getCurrentValuesSummary?token={multi_sensor_token}", - text=load_fixture("efergy_current_values_multi.json"), + text=load_fixture("efergy/efergy_current_values_multi.json"), ) -async def test_single_sensor_readings(hass, requests_mock): +async def test_single_sensor_readings( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +): """Test for successfully setting up the Efergy platform.""" - mock_responses(requests_mock) + mock_responses(aioclient_mock) assert await async_setup_component(hass, "sensor", {"sensor": ONE_SENSOR_CONFIG}) await hass.async_block_till_done() @@ -70,9 +74,11 @@ async def test_single_sensor_readings(hass, requests_mock): assert hass.states.get("sensor.efergy_728386").state == "1628" -async def test_multi_sensor_readings(hass, requests_mock): +async def test_multi_sensor_readings( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +): """Test for multiple sensors in one household.""" - mock_responses(requests_mock) + mock_responses(aioclient_mock) assert await async_setup_component(hass, "sensor", {"sensor": MULTI_SENSOR_CONFIG}) await hass.async_block_till_done() diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index f9df29e16ae..e4d422f9802 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -209,7 +209,7 @@ def hass_hue(loop, hass): @pytest.fixture -def hue_client(loop, hass_hue, aiohttp_client): +def hue_client(loop, hass_hue, hass_client_no_auth): """Create web client for emulated hue api.""" web_app = hass_hue.http.app config = Config( @@ -244,6 +244,7 @@ def hue_client(loop, hass_hue, aiohttp_client): "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, + "127.0.0.1", ) config.numbers = ENTITY_IDS_BY_NUMBER @@ -255,7 +256,7 @@ def hue_client(loop, hass_hue, aiohttp_client): HueFullStateView(config).register(web_app, web_app.router) HueConfigView(config).register(web_app, web_app.router) - return loop.run_until_complete(aiohttp_client(web_app)) + return loop.run_until_complete(hass_client_no_auth()) async def test_discover_lights(hue_client): @@ -302,7 +303,7 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): assert light_without_brightness_json["type"] == "On/Off light" -async def test_lights_all_dimmable(hass, aiohttp_client): +async def test_lights_all_dimmable(hass, hass_client_no_auth): """Test CONF_LIGHTS_ALL_DIMMABLE.""" # create a lamp without brightness support hass.states.async_set("light.no_brightness", "on", {}) @@ -322,11 +323,11 @@ async def test_lights_all_dimmable(hass, aiohttp_client): {emulated_hue.DOMAIN: hue_config}, ) await hass.async_block_till_done() - config = Config(None, hue_config) + config = Config(None, hue_config, "127.0.0.1") config.numbers = ENTITY_IDS_BY_NUMBER web_app = hass.http.app HueOneLightStateView(config).register(web_app, web_app.router) - client = await aiohttp_client(web_app) + client = await hass_client_no_auth() light_without_brightness_json = await perform_get_light_state( client, "light.no_brightness", HTTP_OK ) diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index da15fbfba30..2b0d6fe06c6 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -14,7 +14,7 @@ from tests.common import async_fire_time_changed async def test_config_google_home_entity_id_to_number(hass, hass_storage): """Test config adheres to the type.""" - conf = Config(hass, {"type": "google_home"}) + conf = Config(hass, {"type": "google_home"}, "127.0.0.1") hass_storage[DATA_KEY] = { "version": DATA_VERSION, "key": DATA_KEY, @@ -45,7 +45,7 @@ async def test_config_google_home_entity_id_to_number(hass, hass_storage): async def test_config_google_home_entity_id_to_number_altered(hass, hass_storage): """Test config adheres to the type.""" - conf = Config(hass, {"type": "google_home"}) + conf = Config(hass, {"type": "google_home"}, "127.0.0.1") hass_storage[DATA_KEY] = { "version": DATA_VERSION, "key": DATA_KEY, @@ -76,7 +76,7 @@ async def test_config_google_home_entity_id_to_number_altered(hass, hass_storage async def test_config_google_home_entity_id_to_number_empty(hass, hass_storage): """Test config adheres to the type.""" - conf = Config(hass, {"type": "google_home"}) + conf = Config(hass, {"type": "google_home"}, "127.0.0.1") hass_storage[DATA_KEY] = {"version": DATA_VERSION, "key": DATA_KEY, "data": {}} await conf.async_setup() @@ -100,7 +100,7 @@ async def test_config_google_home_entity_id_to_number_empty(hass, hass_storage): def test_config_alexa_entity_id_to_number(): """Test config adheres to the type.""" - conf = Config(None, {"type": "alexa"}) + conf = Config(None, {"type": "alexa"}, "127.0.0.1") number = conf.entity_id_to_number("light.test") assert number == "light.test" diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index e68688399e0..8ea65380359 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -2,23 +2,19 @@ import json import unittest -from aiohttp.hdrs import CONTENT_TYPE +from aiohttp import web import defusedxml.ElementTree as ET -import requests +import pytest -from homeassistant import const, setup +from homeassistant import setup from homeassistant.components import emulated_hue from homeassistant.components.emulated_hue import upnp from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK -from tests.common import get_test_home_assistant, get_test_instance_port +from tests.common import get_test_instance_port -HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() -BRIDGE_URL_BASE = f"http://127.0.0.1:{BRIDGE_SERVER_PORT}" + "{}" -JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} - class MockTransport: """Mock asyncio transport.""" @@ -32,49 +28,48 @@ class MockTransport: self.sends.append((response, addr)) -class TestEmulatedHue(unittest.TestCase): - """Test the emulated Hue component.""" +@pytest.fixture +def hue_client(aiohttp_client): + """Return a hue API client.""" + app = web.Application() + with unittest.mock.patch( + "homeassistant.components.emulated_hue.web.Application", return_value=app + ): - hass = None + async def client(): + """Return an authenticated client.""" + return await aiohttp_client(app) - @classmethod - def setUpClass(cls): - """Set up the class.""" - cls.hass = hass = get_test_home_assistant() + yield client - setup.setup_component( - hass, - emulated_hue.DOMAIN, - {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, - ) - cls.hass.start() +async def setup_hue(hass): + """Set up the emulated_hue integration.""" + assert await setup.async_setup_component( + hass, + emulated_hue.DOMAIN, + {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, + ) - @classmethod - def tearDownClass(cls): - """Stop the class.""" - cls.hass.stop() - def test_upnp_discovery_basic(self): - """Tests the UPnP basic discovery response.""" - upnp_responder_protocol = upnp.UPNPResponderProtocol( - None, None, "192.0.2.42", 8080 - ) - mock_transport = MockTransport() - upnp_responder_protocol.transport = mock_transport +def test_upnp_discovery_basic(): + """Tests the UPnP basic discovery response.""" + upnp_responder_protocol = upnp.UPNPResponderProtocol(None, None, "192.0.2.42", 8080) + mock_transport = MockTransport() + upnp_responder_protocol.transport = mock_transport - """Original request emitted by the Hue Bridge v1 app.""" - request = """M-SEARCH * HTTP/1.1 + """Original request emitted by the Hue Bridge v1 app.""" + request = """M-SEARCH * HTTP/1.1 HOST:239.255.255.250:1900 ST:ssdp:all Man:"ssdp:discover" MX:3 """ - encoded_request = request.replace("\n", "\r\n").encode("utf-8") + encoded_request = request.replace("\n", "\r\n").encode("utf-8") - upnp_responder_protocol.datagram_received(encoded_request, 1234) - expected_response = """HTTP/1.1 200 OK + upnp_responder_protocol.datagram_received(encoded_request, 1234) + expected_response = """HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: LOCATION: http://192.0.2.42:8080/description.xml @@ -84,30 +79,29 @@ ST: urn:schemas-upnp-org:device:basic:1 USN: uuid:2f402f80-da50-11e1-9b23-001788255acc """ - expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") + expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") - assert mock_transport.sends == [(expected_send, 1234)] + assert mock_transport.sends == [(expected_send, 1234)] - def test_upnp_discovery_rootdevice(self): - """Tests the UPnP rootdevice discovery response.""" - upnp_responder_protocol = upnp.UPNPResponderProtocol( - None, None, "192.0.2.42", 8080 - ) - mock_transport = MockTransport() - upnp_responder_protocol.transport = mock_transport - """Original request emitted by Busch-Jaeger free@home SysAP.""" - request = """M-SEARCH * HTTP/1.1 +def test_upnp_discovery_rootdevice(): + """Tests the UPnP rootdevice discovery response.""" + upnp_responder_protocol = upnp.UPNPResponderProtocol(None, None, "192.0.2.42", 8080) + mock_transport = MockTransport() + upnp_responder_protocol.transport = mock_transport + + """Original request emitted by Busch-Jaeger free@home SysAP.""" + request = """M-SEARCH * HTTP/1.1 HOST: 239.255.255.250:1900 MAN: "ssdp:discover" MX: 40 ST: upnp:rootdevice """ - encoded_request = request.replace("\n", "\r\n").encode("utf-8") + encoded_request = request.replace("\n", "\r\n").encode("utf-8") - upnp_responder_protocol.datagram_received(encoded_request, 1234) - expected_response = """HTTP/1.1 200 OK + upnp_responder_protocol.datagram_received(encoded_request, 1234) + expected_response = """HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: LOCATION: http://192.0.2.42:8080/description.xml @@ -117,95 +111,99 @@ ST: upnp:rootdevice USN: uuid:2f402f80-da50-11e1-9b23-001788255acc::upnp:rootdevice """ - expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") + expected_send = expected_response.replace("\n", "\r\n").encode("utf-8") - assert mock_transport.sends == [(expected_send, 1234)] + assert mock_transport.sends == [(expected_send, 1234)] - def test_upnp_no_response(self): - """Tests the UPnP does not response on an invalid request.""" - upnp_responder_protocol = upnp.UPNPResponderProtocol( - None, None, "192.0.2.42", 8080 - ) - mock_transport = MockTransport() - upnp_responder_protocol.transport = mock_transport - """Original request emitted by the Hue Bridge v1 app.""" - request = """INVALID * HTTP/1.1 +def test_upnp_no_response(): + """Tests the UPnP does not response on an invalid request.""" + upnp_responder_protocol = upnp.UPNPResponderProtocol(None, None, "192.0.2.42", 8080) + mock_transport = MockTransport() + upnp_responder_protocol.transport = mock_transport + + """Original request emitted by the Hue Bridge v1 app.""" + request = """INVALID * HTTP/1.1 HOST:239.255.255.250:1900 ST:ssdp:all Man:"ssdp:discover" MX:3 """ - encoded_request = request.replace("\n", "\r\n").encode("utf-8") + encoded_request = request.replace("\n", "\r\n").encode("utf-8") - upnp_responder_protocol.datagram_received(encoded_request, 1234) + upnp_responder_protocol.datagram_received(encoded_request, 1234) - assert mock_transport.sends == [] + assert mock_transport.sends == [] - def test_description_xml(self): - """Test the description.""" - result = requests.get(BRIDGE_URL_BASE.format("/description.xml"), timeout=5) - assert result.status_code == HTTP_OK - assert "text/xml" in result.headers["content-type"] +async def test_description_xml(hass, hue_client): + """Test the description.""" + await setup_hue(hass) + client = await hue_client() + result = await client.get("/description.xml", timeout=5) - # Make sure the XML is parsable - try: - root = ET.fromstring(result.text) - ns = {"s": "urn:schemas-upnp-org:device-1-0"} - assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" - except: # noqa: E722 pylint: disable=bare-except - self.fail("description.xml is not valid XML!") + assert result.status == HTTP_OK + assert "text/xml" in result.headers["content-type"] - def test_create_username(self): - """Test the creation of an username.""" - request_json = {"devicetype": "my_device"} + try: + root = ET.fromstring(await result.text()) + ns = {"s": "urn:schemas-upnp-org:device-1-0"} + assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" + except: # noqa: E722 pylint: disable=bare-except + pytest.fail("description.xml is not valid XML!") - result = requests.post( - BRIDGE_URL_BASE.format("/api"), data=json.dumps(request_json), timeout=5 - ) - assert result.status_code == HTTP_OK - assert CONTENT_TYPE_JSON in result.headers["content-type"] +async def test_create_username(hass, hue_client): + """Test the creation of an username.""" + await setup_hue(hass) + client = await hue_client() + request_json = {"devicetype": "my_device"} - resp_json = result.json() - success_json = resp_json[0] + result = await client.post("/api", data=json.dumps(request_json), timeout=5) - assert "success" in success_json - assert "username" in success_json["success"] + assert result.status == HTTP_OK + assert CONTENT_TYPE_JSON in result.headers["content-type"] - def test_unauthorized_view(self): - """Test unauthorized view.""" - request_json = {"devicetype": "my_device"} + resp_json = await result.json() + success_json = resp_json[0] - result = requests.get( - BRIDGE_URL_BASE.format("/api/unauthorized"), - data=json.dumps(request_json), - timeout=5, - ) + assert "success" in success_json + assert "username" in success_json["success"] - assert result.status_code == HTTP_OK - assert CONTENT_TYPE_JSON in result.headers["content-type"] - resp_json = result.json() - assert len(resp_json) == 1 - success_json = resp_json[0] - assert len(success_json) == 1 +async def test_unauthorized_view(hass, hue_client): + """Test unauthorized view.""" + await setup_hue(hass) + client = await hue_client() + request_json = {"devicetype": "my_device"} - assert "error" in success_json - error_json = success_json["error"] - assert len(error_json) == 3 - assert "/" in error_json["address"] - assert "unauthorized user" in error_json["description"] - assert "1" in error_json["type"] + result = await client.get( + "/api/unauthorized", data=json.dumps(request_json), timeout=5 + ) - def test_valid_username_request(self): - """Test request with a valid username.""" - request_json = {"invalid_key": "my_device"} + assert result.status == HTTP_OK + assert CONTENT_TYPE_JSON in result.headers["content-type"] - result = requests.post( - BRIDGE_URL_BASE.format("/api"), data=json.dumps(request_json), timeout=5 - ) + resp_json = await result.json() + assert len(resp_json) == 1 + success_json = resp_json[0] + assert len(success_json) == 1 - assert result.status_code == 400 + assert "error" in success_json + error_json = success_json["error"] + assert len(error_json) == 3 + assert "/" in error_json["address"] + assert "unauthorized user" in error_json["description"] + assert "1" in error_json["type"] + + +async def test_valid_username_request(hass, hue_client): + """Test request with a valid username.""" + await setup_hue(hass) + client = await hue_client() + request_json = {"invalid_key": "my_device"} + + result = await client.post("/api", data=json.dumps(request_json), timeout=5) + + assert result.status == 400 diff --git a/tests/components/emulated_roku/test_config_flow.py b/tests/components/emulated_roku/test_config_flow.py index 23c807cbfa3..3d1438dafb9 100644 --- a/tests/components/emulated_roku/test_config_flow.py +++ b/tests/components/emulated_roku/test_config_flow.py @@ -5,7 +5,7 @@ from homeassistant.components.emulated_roku import config_flow from tests.common import MockConfigEntry -async def test_flow_works(hass): +async def test_flow_works(hass, mock_get_source_ip): """Test that config flow works.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -18,7 +18,7 @@ async def test_flow_works(hass): assert result["data"] == {"name": "Emulated Roku Test", "listen_port": 8060} -async def test_flow_already_registered_entry(hass): +async def test_flow_already_registered_entry(hass, mock_get_source_ip): """Test that config flow doesn't allow existing names.""" MockConfigEntry( domain="emulated_roku", data={"name": "Emulated Roku Test", "listen_port": 8062} diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index d69df5a1fbe..93db9124414 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -5,7 +5,7 @@ from homeassistant.components import emulated_roku from homeassistant.setup import async_setup_component -async def test_config_required_fields(hass): +async def test_config_required_fields(hass, mock_get_source_ip): """Test that configuration is successful with required fields.""" with patch.object(emulated_roku, "configured_servers", return_value=[]), patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", @@ -30,7 +30,7 @@ async def test_config_required_fields(hass): ) -async def test_config_already_registered_not_configured(hass): +async def test_config_already_registered_not_configured(hass, mock_get_source_ip): """Test that an already registered name causes the entry to be ignored.""" with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 31c73fc5b7a..dc9b28b55b9 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -7,7 +7,10 @@ import pytest from homeassistant.components.energy import data from homeassistant.components.sensor import ( + ATTR_LAST_RESET, ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.sensor.recorder import compile_statistics @@ -35,6 +38,14 @@ async def setup_integration(hass): await hass.async_block_till_done() +def get_statistics_for_entity(statistics_results, entity_id): + """Get statistics for a certain entity, or None if there is none.""" + for statistics_result in statistics_results: + if statistics_result["meta"]["statistic_id"] == entity_id: + return statistics_result + return None + + async def test_cost_sensor_no_states(hass, hass_storage) -> None: """Test sensors are created.""" energy_data = data.EnergyManager.default_preferences() @@ -154,8 +165,8 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY if initial_cost != "unknown": - assert state.attributes["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -171,8 +182,8 @@ async def test_cost_sensor_price_entity_total_increasing( 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["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -189,7 +200,7 @@ async def test_cost_sensor_price_entity_total_increasing( 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 - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -204,7 +215,7 @@ async def test_cost_sensor_price_entity_total_increasing( 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 - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -215,13 +226,13 @@ async def test_cost_sensor_price_entity_total_increasing( 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 - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # 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 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 19.0 # Energy sensor has a small dip, no reset should be detected hass.states.async_set( @@ -232,7 +243,7 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( @@ -243,8 +254,8 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR - assert state.attributes["last_reset"] != last_reset_cost_sensor - last_reset_cost_sensor = state.attributes["last_reset"] + assert state.attributes[ATTR_LAST_RESET] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh hass.states.async_set( @@ -255,13 +266,13 @@ async def test_cost_sensor_price_entity_total_increasing( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # 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"] == 38.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 38.0 @pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) @@ -279,7 +290,7 @@ async def test_cost_sensor_price_entity_total_increasing( ), ], ) -@pytest.mark.parametrize("energy_state_class", ["measurement"]) +@pytest.mark.parametrize("energy_state_class", ["total", "measurement"]) async def test_cost_sensor_price_entity_total( hass, hass_storage, @@ -359,8 +370,8 @@ async def test_cost_sensor_price_entity_total( assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY if initial_cost != "unknown": - assert state.attributes["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -376,8 +387,8 @@ async def test_cost_sensor_price_entity_total( 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["last_reset"] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -394,7 +405,7 @@ async def test_cost_sensor_price_entity_total( 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 - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -409,7 +420,7 @@ async def test_cost_sensor_price_entity_total( 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 - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -420,13 +431,13 @@ async def test_cost_sensor_price_entity_total( 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 - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # 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 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 19.0 # Energy sensor has a small dip hass.states.async_set( @@ -437,7 +448,7 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point last_reset = (now + timedelta(seconds=1)).isoformat() @@ -449,8 +460,8 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR - assert state.attributes["last_reset"] != last_reset_cost_sensor - last_reset_cost_sensor = state.attributes["last_reset"] + assert state.attributes[ATTR_LAST_RESET] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh hass.states.async_set( @@ -461,13 +472,194 @@ async def test_cost_sensor_price_entity_total( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR - assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # 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"] == 38.0 + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 38.0 + + +@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", + ), + ], +) +@pytest.mark.parametrize("energy_state_class", ["total"]) +async def test_cost_sensor_price_entity_total_no_reset( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, + energy_state_class, +) -> None: + """Test energy cost price from total type sensor entity with no last_reset.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: energy_state_class, + } + + 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_cost_sensor = now.isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + energy_attributes, + ) + 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] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + 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", + energy_attributes, + ) + 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] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + 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", + energy_attributes, + ) + 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 + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # 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 + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + energy_attributes, + ) + 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 + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 19.0 + + # Energy sensor has a small dip + hass.states.async_set( + usage_sensor_entity_id, + "14", + energy_attributes, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + all_statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + statistics = get_statistics_for_entity(all_statistics, cost_sensor_entity_id) + assert statistics["stat"][0]["sum"] == 18.0 async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: @@ -579,7 +771,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects state_class with wrong state_class.""" + """Test energy sensor rejects sensor with wrong state_class.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, @@ -637,11 +829,11 @@ async def test_cost_sensor_wrong_state_class( assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", [STATE_CLASS_MEASUREMENT]) async def test_cost_sensor_state_class_measurement_no_reset( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects state_class with no last_reset.""" + """Test energy sensor rejects state_class measurement with no last_reset.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 8c67f3eabaf..668f3113fea 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -39,13 +39,27 @@ async def test_validation_empty_config(hass): } -async def test_validation(hass, mock_energy_manager): +@pytest.mark.parametrize( + "state_class, extra", + [ + ("total_increasing", {}), + ("total", {}), + ("total", {"last_reset": "abc"}), + ("measurement", {"last_reset": "abc"}), + ], +) +async def test_validation(hass, mock_energy_manager, state_class, extra): """Test validating success.""" for key in ("device_cons", "battery_import", "battery_export", "solar_production"): hass.states.async_set( f"sensor.{key}", "123", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": state_class, + **extra, + }, ) await mock_energy_manager.async_update( @@ -142,7 +156,11 @@ async def test_validation_device_consumption_entity_unexpected_unit( hass.states.async_set( "sensor.unexpected_unit", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -182,6 +200,35 @@ async def test_validation_device_consumption_recorder_not_tracked( } +async def test_validation_device_consumption_no_last_reset(hass, mock_energy_manager): + """Test validating device based on untracked entity.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.no_last_reset"}]} + ) + hass.states.async_set( + "sensor.no_last_reset", + "10.10", + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "measurement", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_state_class_measurement_no_last_reset", + "identifier": "sensor.no_last_reset", + "value": None, + } + ] + ], + } + + async def test_validation_solar(hass, mock_energy_manager): """Test validating missing stat for device.""" await mock_energy_manager.async_update( @@ -194,7 +241,11 @@ async def test_validation_solar(hass, mock_energy_manager): hass.states.async_set( "sensor.solar_production", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -227,12 +278,20 @@ async def test_validation_battery(hass, mock_energy_manager): hass.states.async_set( "sensor.battery_import", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.battery_export", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -282,12 +341,20 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde hass.states.async_set( "sensor.grid_consumption_1", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.grid_production_1", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) assert (await validate.async_validate(hass)).as_dict() == { @@ -303,6 +370,11 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde "identifier": "sensor.grid_cost_1", "value": None, }, + { + "type": "entity_not_defined", + "identifier": "sensor.grid_cost_1", + "value": None, + }, { "type": "entity_unexpected_unit_energy", "identifier": "sensor.grid_production_1", @@ -313,6 +385,11 @@ async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorde "identifier": "sensor.grid_compensation_1", "value": None, }, + { + "type": "entity_not_defined", + "identifier": "sensor.grid_compensation_1", + "value": None, + }, ] ], "device_consumption": [], @@ -324,12 +401,20 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): hass.states.async_set( "sensor.grid_consumption_1", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.grid_production_1", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) await mock_energy_manager.async_update( { @@ -341,12 +426,14 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "stat_energy_from": "sensor.grid_consumption_1", "entity_energy_from": "sensor.grid_consumption_1", "entity_energy_price": "sensor.grid_price_1", + "number_energy_price": None, } ], "flow_to": [ { "stat_energy_to": "sensor.grid_production_1", "entity_energy_to": "sensor.grid_production_1", + "entity_energy_price": None, "number_energy_price": 0.10, } ], @@ -386,7 +473,7 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "123", "$/Ws", { - "type": "entity_unexpected_unit_price", + "type": "entity_unexpected_unit_energy_price", "identifier": "sensor.grid_price_1", "value": "$/Ws", }, @@ -400,7 +487,11 @@ async def test_validation_grid_price_errors( hass.states.async_set( "sensor.grid_consumption_1", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.grid_price_1", @@ -417,6 +508,7 @@ async def test_validation_grid_price_errors( "stat_energy_from": "sensor.grid_consumption_1", "entity_energy_from": "sensor.grid_consumption_1", "entity_energy_price": "sensor.grid_price_1", + "number_energy_price": None, } ], "flow_to": [], @@ -451,24 +543,74 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded "stat_energy_from": "sensor.gas_consumption_2", "stat_cost": "sensor.gas_cost_2", }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_3", + "stat_cost": "sensor.gas_cost_2", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_4", + "entity_energy_from": "sensor.gas_consumption_4", + "entity_energy_price": "sensor.gas_price_1", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_3", + "entity_energy_from": "sensor.gas_consumption_3", + "entity_energy_price": "sensor.gas_price_2", + }, ] } ) + await hass.async_block_till_done() hass.states.async_set( "sensor.gas_consumption_1", "10.10", - {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, ) hass.states.async_set( "sensor.gas_consumption_2", "10.10", - {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.gas_consumption_3", + "10.10", + { + "device_class": "gas", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.gas_consumption_4", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, ) hass.states.async_set( "sensor.gas_cost_2", "10.10", {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, ) + hass.states.async_set( + "sensor.gas_price_1", + "10.10", + {"unit_of_measurement": "EUR/m³", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_price_2", + "10.10", + {"unit_of_measurement": "EUR/invalid", "state_class": "total_increasing"}, + ) assert (await validate.async_validate(hass)).as_dict() == { "energy_sources": [ @@ -483,8 +625,110 @@ async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded "identifier": "sensor.gas_cost_1", "value": None, }, + { + "type": "entity_not_defined", + "identifier": "sensor.gas_cost_1", + "value": None, + }, ], [], + [], + [ + { + "type": "entity_unexpected_device_class", + "identifier": "sensor.gas_consumption_4", + "value": None, + }, + ], + [ + { + "type": "entity_unexpected_unit_gas_price", + "identifier": "sensor.gas_price_2", + "value": "EUR/invalid", + }, + ], ], "device_consumption": [], } + + +async def test_validation_gas_no_costs_tracking( + hass, mock_energy_manager, mock_is_entity_recorded +): + """Test validating gas with sensors without cost tracking.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_1", + "stat_cost": None, + "entity_energy_from": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + ] + } + ) + hass.states.async_set( + "sensor.gas_consumption_1", + "10.10", + { + "device_class": "gas", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } + + +async def test_validation_grid_no_costs_tracking( + hass, mock_energy_manager, mock_is_entity_recorded +): + """Test validating grid with sensors for energy without cost tracking.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_energy", + "stat_cost": None, + "entity_energy_from": "sensor.grid_energy", + "entity_energy_price": None, + "number_energy_price": None, + }, + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_energy", + "stat_cost": None, + "entity_energy_to": "sensor.grid_energy", + "entity_energy_price": None, + "number_energy_price": None, + }, + ], + "cost_adjustment_day": 0.0, + } + ] + } + ) + hass.states.async_set( + "sensor.grid_energy", + "10.10", + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index 9c02feadc1a..088b1a5435c 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -21,6 +21,9 @@ async def test_form(hass): with patch( "homeassistant.components.epson.Projector.get_power", return_value="01", + ), patch( + "homeassistant.components.epson.Projector.get_serial_number", + return_value="12345", ), patch( "homeassistant.components.epson.async_setup_entry", return_value=True, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index a5de14d946d..b7916a3af8d 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2,10 +2,17 @@ from collections import namedtuple from unittest.mock import AsyncMock, MagicMock, patch +from aioesphomeapi import ( + APIConnectionError, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, + ResolveAPIError, +) import pytest from homeassistant import config_entries -from homeassistant.components.esphome import DOMAIN, DomainData +from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, DomainData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -16,6 +23,8 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"]) +VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" +INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" @pytest.fixture @@ -23,12 +32,15 @@ def mock_client(): """Mock APIClient.""" with patch("homeassistant.components.esphome.config_flow.APIClient") as mock_client: - def mock_constructor(loop, host, port, password, zeroconf_instance=None): + def mock_constructor( + loop, host, port, password, zeroconf_instance=None, noise_psk=None + ): """Fake the client constructor.""" mock_client.host = host mock_client.port = port mock_client.password = password mock_client.zeroconf_instance = zeroconf_instance + mock_client.noise_psk = noise_psk return mock_client mock_client.side_effect = mock_constructor @@ -38,16 +50,6 @@ def mock_client(): yield mock_client -@pytest.fixture(autouse=True) -def mock_api_connection_error(): - """Mock out the try login method.""" - with patch( - "homeassistant.components.esphome.config_flow.APIConnectionError", - new_callable=lambda: OSError, - ) as mock_error: - yield mock_error - - @pytest.fixture(autouse=True) def mock_setup_entry(): """Mock setting up a config entry.""" @@ -55,7 +57,7 @@ def mock_setup_entry(): yield -async def test_user_connection_works(hass, mock_client): +async def test_user_connection_works(hass, mock_client, mock_zeroconf): """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( "esphome", @@ -75,7 +77,12 @@ async def test_user_connection_works(hass, mock_client): ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"] == {CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSWORD: ""} + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } assert result["title"] == "test" assert len(mock_client.connect.mock_calls) == 1 @@ -84,21 +91,15 @@ async def test_user_connection_works(hass, mock_client): assert mock_client.host == "127.0.0.1" assert mock_client.port == 80 assert mock_client.password == "" + assert mock_client.noise_psk is None -async def test_user_resolve_error(hass, mock_api_connection_error, mock_client): +async def test_user_resolve_error(hass, mock_client, mock_zeroconf): """Test user step with IP resolve error.""" - class MockResolveError(mock_api_connection_error): - """Create an exception with a specific error message.""" - - def __init__(self): - """Initialize.""" - super().__init__("Error resolving IP address") - with patch( "homeassistant.components.esphome.config_flow.APIConnectionError", - new_callable=lambda: MockResolveError, + new_callable=lambda: ResolveAPIError, ) as exc: mock_client.device_info.side_effect = exc result = await hass.config_entries.flow.async_init( @@ -116,9 +117,9 @@ async def test_user_resolve_error(hass, mock_api_connection_error, mock_client): assert len(mock_client.disconnect.mock_calls) == 1 -async def test_user_connection_error(hass, mock_api_connection_error, mock_client): +async def test_user_connection_error(hass, mock_client, mock_zeroconf): """Test user step with connection error.""" - mock_client.device_info.side_effect = mock_api_connection_error + mock_client.device_info.side_effect = APIConnectionError result = await hass.config_entries.flow.async_init( "esphome", @@ -135,7 +136,7 @@ async def test_user_connection_error(hass, mock_api_connection_error, mock_clien assert len(mock_client.disconnect.mock_calls) == 1 -async def test_user_with_password(hass, mock_client): +async def test_user_with_password(hass, mock_client, mock_zeroconf): """Test user step with password.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) @@ -157,11 +158,12 @@ async def test_user_with_password(hass, mock_client): CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "password1", + CONF_NOISE_PSK: "", } assert mock_client.password == "password1" -async def test_user_invalid_password(hass, mock_api_connection_error, mock_client): +async def test_user_invalid_password(hass, mock_client, mock_zeroconf): """Test user step with invalid password.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) @@ -174,7 +176,7 @@ async def test_user_invalid_password(hass, mock_api_connection_error, mock_clien assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - mock_client.connect.side_effect = mock_api_connection_error + mock_client.connect.side_effect = InvalidAuthAPIError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "invalid"} @@ -185,7 +187,31 @@ async def test_user_invalid_password(hass, mock_api_connection_error, mock_clien assert result["errors"] == {"base": "invalid_auth"} -async def test_discovery_initiation(hass, mock_client): +async def test_login_connection_error(hass, mock_client, mock_zeroconf): + """Test user step with connection error on login attempt.""" + mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + mock_client.connect.side_effect = APIConnectionError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "valid"} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + assert result["errors"] == {"base": "connection_error"} + + +async def test_discovery_initiation(hass, mock_client, mock_zeroconf): """Test discovery importing works.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) @@ -339,3 +365,151 @@ async def test_discovery_updates_unique_id(hass, mock_client): assert result["reason"] == "already_configured" assert entry.unique_id == "test8266" + + +async def test_user_requires_psk(hass, mock_client, mock_zeroconf): + """Test user step with requiring encryption key.""" + mock_client.device_info.side_effect = RequiresEncryptionAPIError + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {} + + assert len(mock_client.connect.mock_calls) == 1 + assert len(mock_client.device_info.mock_calls) == 1 + assert len(mock_client.disconnect.mock_calls) == 1 + + +async def test_encryption_key_valid_psk(hass, mock_client, mock_zeroconf): + """Test encryption key step with valid key.""" + + mock_client.device_info.side_effect = RequiresEncryptionAPIError + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + + mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +async def test_encryption_key_invalid_psk(hass, mock_client, mock_zeroconf): + """Test encryption key step with invalid key.""" + + mock_client.device_info.side_effect = RequiresEncryptionAPIError + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {"base": "invalid_psk"} + assert mock_client.noise_psk == INVALID_NOISE_PSK + + +async def test_reauth_initiation(hass, mock_client, mock_zeroconf): + """Test reauth initiation shows form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + +async def test_reauth_confirm_valid(hass, mock_client, mock_zeroconf): + """Test reauth initiation with valid PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf): + """Test reauth initiation with invalid PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] + assert result["errors"]["base"] == "invalid_psk" diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 60fae0fc5be..89e8758c661 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -15,8 +15,17 @@ from homeassistant.components.filter.sensor import ( TimeSMAFilter, TimeThrottleFilter, ) -from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE -from homeassistant.const import SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + SERVICE_RELOAD, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -264,12 +273,17 @@ async def test_setup(hass): hass.states.async_set( "sensor.test_monitored", 1, - {"icon": "mdi:test", "device_class": DEVICE_CLASS_TEMPERATURE}, + { + "icon": "mdi:test", + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test") assert state.attributes["icon"] == "mdi:test" - assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING assert state.state == "1.0" diff --git a/tests/components/flipr/test_binary_sensor.py b/tests/components/flipr/test_binary_sensor.py new file mode 100644 index 00000000000..48f9361723c --- /dev/null +++ b/tests/components/flipr/test_binary_sensor.py @@ -0,0 +1,58 @@ +"""Test the Flipr binary sensor.""" +from datetime import datetime +from unittest.mock import patch + +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +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 binary 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() + + 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() + + # Check entity unique_id value that is generated in FliprEntity base class. + entity = registry.async_get("binary_sensor.flipr_myfliprid_ph_status") + assert entity.unique_id == "myfliprid-ph_status" + + state = hass.states.get("binary_sensor.flipr_myfliprid_ph_status") + assert state + assert state.state == "on" # Alert is on for binary sensor + + state = hass.states.get("binary_sensor.flipr_myfliprid_chlorine_status") + assert state + assert state.state == "off" diff --git a/tests/components/flipr/test_sensors.py b/tests/components/flipr/test_sensor.py similarity index 88% rename from tests/components/flipr/test_sensors.py rename to tests/components/flipr/test_sensor.py index 244ec61507c..7fd04fbc992 100644 --- a/tests/components/flipr/test_sensors.py +++ b/tests/components/flipr/test_sensor.py @@ -1,9 +1,8 @@ -"""Test the Flipr sensor and binary sensor.""" +"""Test the Flipr 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, @@ -45,15 +44,6 @@ async def test_sensors(hass: HomeAssistant) -> None: 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, @@ -61,6 +51,10 @@ async def test_sensors(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + # Check entity unique_id value that is generated in FliprEntity base class. + entity = registry.async_get("sensor.flipr_myfliprid_red_ox") + assert entity.unique_id == "myfliprid-red_ox" + state = hass.states.get("sensor.flipr_myfliprid_ph") assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" diff --git a/tests/components/freedompro/conftest.py b/tests/components/freedompro/conftest.py index c43887fa487..36070c1a0d5 100644 --- a/tests/components/freedompro/conftest.py +++ b/tests/components/freedompro/conftest.py @@ -9,6 +9,22 @@ from tests.common import MockConfigEntry from tests.components.freedompro.const import DEVICES, DEVICES_STATE +@pytest.fixture(autouse=True) +def mock_freedompro(): + """Mock freedompro get_list and get_states.""" + with patch( + "homeassistant.components.freedompro.get_list", + return_value={ + "state": True, + "devices": DEVICES, + }, + ), patch( + "homeassistant.components.freedompro.get_states", + return_value=DEVICES_STATE, + ): + yield + + @pytest.fixture async def init_integration(hass) -> MockConfigEntry: """Set up the Freedompro integration in Home Assistant.""" @@ -21,19 +37,9 @@ async def init_integration(hass) -> MockConfigEntry: }, ) - with patch( - "homeassistant.components.freedompro.get_list", - return_value={ - "state": True, - "devices": DEVICES, - }, - ), patch( - "homeassistant.components.freedompro.get_states", - return_value=DEVICES_STATE, - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index f44cbd232ad..42dc0674d07 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -26,7 +26,7 @@ async def test_show_form(hass): async def test_invalid_auth(hass): """Test that errors are shown when API key is invalid.""" with patch( - "homeassistant.components.freedompro.config_flow.list", + "homeassistant.components.freedompro.config_flow.get_list", return_value={ "state": False, "code": -201, diff --git a/tests/components/freedompro/test_light.py b/tests/components/freedompro/test_light.py index 09a945ada03..b23ebf85676 100644 --- a/tests/components/freedompro/test_light.py +++ b/tests/components/freedompro/test_light.py @@ -1,4 +1,8 @@ """Tests for the Freedompro light.""" +from unittest.mock import patch + +import pytest + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -9,6 +13,13 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, STATE_OFF, STA from homeassistant.helpers import entity_registry as er +@pytest.fixture(autouse=True) +def mock_freedompro_put_state(): + """Mock freedompro put_state.""" + with patch("homeassistant.components.freedompro.light.put_state"): + yield + + async def test_light_get_state(hass, init_integration): """Test states of the light.""" init_integration diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 1551a508277..0aecefedf0d 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -42,7 +42,7 @@ ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" MOCK_HOST = "fake_host" MOCK_SERIAL_NUMBER = "fake_serial_number" - +MOCK_FIRMWARE_INFO = [True, "1.1.1"] MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_DEVICE_INFO = { @@ -67,12 +67,15 @@ def fc_class_mock(): yield result -async def test_user(hass: HomeAssistant, fc_class_mock): +async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test starting a flow by user.""" with patch( "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -108,7 +111,9 @@ async def test_user(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_user_already_configured(hass: HomeAssistant, fc_class_mock): +async def test_user_already_configured( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow by user with an already configured device.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) @@ -118,6 +123,9 @@ async def test_user_already_configured(hass: HomeAssistant, fc_class_mock): "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "requests.get" ) as mock_request_get, patch( "requests.post" @@ -142,7 +150,7 @@ async def test_user_already_configured(hass: HomeAssistant, fc_class_mock): assert result["errors"]["base"] == "already_configured" -async def test_exception_security(hass: HomeAssistant): +async def test_exception_security(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow by user with invalid credentials.""" result = await hass.config_entries.flow.async_init( @@ -165,7 +173,7 @@ async def test_exception_security(hass: HomeAssistant): assert result["errors"]["base"] == ERROR_AUTH_INVALID -async def test_exception_connection(hass: HomeAssistant): +async def test_exception_connection(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -188,7 +196,7 @@ async def test_exception_connection(hass: HomeAssistant): assert result["errors"]["base"] == ERROR_CANNOT_CONNECT -async def test_exception_unknown(hass: HomeAssistant): +async def test_exception_unknown(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow by user with an unknown exception.""" result = await hass.config_entries.flow.async_init( @@ -211,7 +219,9 @@ async def test_exception_unknown(hass: HomeAssistant): assert result["errors"]["base"] == ERROR_UNKNOWN -async def test_reauth_successful(hass: HomeAssistant, fc_class_mock): +async def test_reauth_successful( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a reauthentication flow.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) @@ -221,6 +231,9 @@ async def test_reauth_successful(hass: HomeAssistant, fc_class_mock): "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -256,7 +269,9 @@ async def test_reauth_successful(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_reauth_not_successful(hass: HomeAssistant, fc_class_mock): +async def test_reauth_not_successful( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a reauthentication flow but no connection found.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) @@ -289,7 +304,9 @@ async def test_reauth_not_successful(hass: HomeAssistant, fc_class_mock): assert result["errors"]["base"] == "cannot_connect" -async def test_ssdp_already_configured(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_configured( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery with an already configured device.""" mock_config = MockConfigEntry( @@ -311,7 +328,9 @@ async def test_ssdp_already_configured(hass: HomeAssistant, fc_class_mock): assert result["reason"] == "already_configured" -async def test_ssdp_already_configured_host(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_configured_host( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery with an already configured host.""" mock_config = MockConfigEntry( @@ -333,7 +352,9 @@ async def test_ssdp_already_configured_host(hass: HomeAssistant, fc_class_mock): assert result["reason"] == "already_configured" -async def test_ssdp_already_configured_host_uuid(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_configured_host_uuid( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery with an already configured uuid.""" mock_config = MockConfigEntry( @@ -355,7 +376,9 @@ async def test_ssdp_already_configured_host_uuid(hass: HomeAssistant, fc_class_m assert result["reason"] == "already_configured" -async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fc_class_mock): +async def test_ssdp_already_in_progress_host( + hass: HomeAssistant, fc_class_mock, mock_get_source_ip +): """Test starting a flow from discovery twice.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -377,12 +400,15 @@ async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fc_class_mock) assert result["reason"] == "already_in_progress" -async def test_ssdp(hass: HomeAssistant, fc_class_mock): +async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test starting a flow from discovery.""" with patch( "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -417,7 +443,7 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_ssdp_exception(hass: HomeAssistant): +async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.fritz.common.FritzConnection", @@ -442,12 +468,15 @@ async def test_ssdp_exception(hass: HomeAssistant): assert result["step_id"] == "confirm" -async def test_import(hass: HomeAssistant, fc_class_mock): +async def test_import(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test importing.""" with patch( "homeassistant.components.fritz.common.FritzConnection", side_effect=fc_class_mock, ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( "requests.get" @@ -473,7 +502,7 @@ async def test_import(hass: HomeAssistant, fc_class_mock): assert mock_setup_entry.called -async def test_options_flow(hass: HomeAssistant, fc_class_mock): +async def test_options_flow(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test options flow.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index da6bd982d9d..2b9a1a783f9 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -100,6 +100,7 @@ class FritzDeviceSensorMock(FritzDeviceBaseMock): lock = "fake_locked" present = True temperature = 1.23 + rel_humidity = 42 class FritzDeviceSwitchMock(FritzDeviceBaseMock): @@ -108,6 +109,7 @@ class FritzDeviceSwitchMock(FritzDeviceBaseMock): battery_level = None device_lock = "fake_locked_device" energy = 1234 + voltage = 230 fw_version = "1.2.3" has_alarm = False has_powermeter = True diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 664b6765c03..f7a3ef9ae2a 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -49,6 +49,13 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): 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}_humidity") + assert state + assert state.state == "42" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Humidity" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"{ENTITY_ID}_battery") assert state assert state.state == "23" diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 27461b2790f..fb7221262d3 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -26,6 +26,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -63,6 +64,9 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): 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}_humidity") + assert state is None + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power_consumption") assert state assert state.state == "5.678" @@ -137,3 +141,18 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock): assert device.update.call_count == 2 assert fritz().login.call_count == 2 + + +async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock): + """Test assume device as unavailable.""" + device = FritzDeviceSwitchMock() + device.voltage = 0 + device.energy = 0 + device.power = 0 + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 1a1edc4eece..8642a6a7fac 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -50,9 +50,10 @@ async def test_fetching_url(hass, hass_client): assert respx.calls.call_count == 2 -async def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): +@respx.mock +async def test_fetching_without_verify_ssl(hass, hass_client): """Test that it fetches the given url when ssl verify is off.""" - aioclient_mock.get("https://example.com", text="hello world") + respx.get("https://example.com").respond(text="hello world") await async_setup_component( hass, @@ -77,9 +78,10 @@ async def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): assert resp.status == 200 -async def test_fetching_url_with_verify_ssl(aioclient_mock, hass, hass_client): +@respx.mock +async def test_fetching_url_with_verify_ssl(hass, hass_client): """Test that it fetches the given url when ssl verify is explicitly on.""" - aioclient_mock.get("https://example.com", text="hello world") + respx.get("https://example.com").respond(text="hello world") await async_setup_component( hass, @@ -169,7 +171,7 @@ async def test_limit_refetch(hass, hass_client): assert body == "hello planet" -async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): +async def test_stream_source(hass, hass_client, hass_ws_client): """Test that the stream source is rendered.""" assert await async_setup_component( hass, @@ -209,7 +211,7 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): assert msg["result"]["url"][-13:] == "playlist.m3u8" -async def test_stream_source_error(aioclient_mock, hass, hass_client, hass_ws_client): +async def test_stream_source_error(hass, hass_client, hass_ws_client): """Test that the stream source has an error.""" assert await async_setup_component( hass, @@ -273,7 +275,7 @@ async def test_setup_alternative_options(hass, hass_ws_client): assert hass.data["camera"].get_entity("camera.config_test") -async def test_no_stream_source(aioclient_mock, hass, hass_client, hass_ws_client): +async def test_no_stream_source(hass, hass_client, hass_ws_client): """Test a stream request without stream source option set.""" assert await async_setup_component( hass, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 317f9d3e74e..4015887efbb 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -325,6 +325,21 @@ async def test_set_away_mode_twice_and_restore_prev_temp(hass, setup_comp_2): assert state.attributes.get("temperature") == 23 +async def test_set_preset_mode_invalid(hass, setup_comp_2): + """Test an invalid mode raises an error and ignore case when checking modes.""" + await common.async_set_temperature(hass, 23) + await common.async_set_preset_mode(hass, "away") + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == "away" + await common.async_set_preset_mode(hass, "none") + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == "none" + with pytest.raises(ValueError): + await common.async_set_preset_mode(hass, "Sleep") + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == "none" + + async def test_sensor_bad_value(hass, setup_comp_2): """Test sensor that have None as state.""" state = hass.states.get(ENTITY) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 169cfebae17..8646eac19a2 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -118,7 +118,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def geofency_client(loop, hass, aiohttp_client): +async def geofency_client(loop, hass, hass_client_no_auth): """Geofency mock client (unauthenticated).""" assert await async_setup_component(hass, "persistent_notification", {}) @@ -128,7 +128,7 @@ async def geofency_client(loop, hass, aiohttp_client): await hass.async_block_till_done() with patch("homeassistant.components.device_tracker.legacy.update_config"): - return await aiohttp_client(hass.http.app) + return await hass_client_no_auth() @pytest.fixture(autouse=True) diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index c9a2c333b8b..1b2c2434fab 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -1,13 +1,13 @@ """Tests for Glances config flow.""" from unittest.mock import patch -from glances_api import Glances +from glances_api import exceptions from homeassistant import config_entries, data_entry_flow from homeassistant.components import glances from homeassistant.const import CONF_SCAN_INTERVAL -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry NAME = "Glances" HOST = "0.0.0.0" @@ -38,9 +38,7 @@ async def test_form(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - with patch("glances_api.Glances"), patch.object( - Glances, "get_data", return_value=mock_coro() - ): + with patch("homeassistant.components.glances.Glances.get_data", autospec=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT @@ -54,7 +52,10 @@ async def test_form(hass): async def test_form_cannot_connect(hass): """Test to return error if we cannot connect.""" - with patch("glances_api.Glances"): + with patch( + "homeassistant.components.glances.Glances.get_data", + side_effect=exceptions.GlancesApiConnectionError, + ): result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2c3a61b8beb..397b4c309a7 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -36,7 +36,7 @@ def auth_header(hass_access_token): @pytest.fixture -def assistant_client(loop, hass, aiohttp_client): +def assistant_client(loop, hass, hass_client_no_auth): """Create web client for the Google Assistant API.""" loop.run_until_complete( setup.async_setup_component( @@ -56,7 +56,7 @@ def assistant_client(loop, hass, aiohttp_client): ) ) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client_no_auth()) @pytest.fixture diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 69a8242b7cc..013fa3c1d0c 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -153,6 +153,10 @@ async def test_report_state(hass, aioclient_mock, hass_storage): await config.async_connect_agent_user(agent_user_id) message = {"devices": {}} + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + with patch.object(config, "async_call_homegraph_api") as mock_call: await config.async_report_state(message, agent_user_id) mock_call.assert_called_once_with( diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 61e5862d3b1..4305b8d5642 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -31,7 +31,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def gpslogger_client(loop, hass, aiohttp_client): +async def gpslogger_client(loop, hass, hass_client_no_auth): """Mock client for GPSLogger (unauthenticated).""" assert await async_setup_component(hass, "persistent_notification", {}) @@ -40,7 +40,7 @@ async def gpslogger_client(loop, hass, aiohttp_client): await hass.async_block_till_done() with patch("homeassistant.components.device_tracker.legacy.update_config"): - return await aiohttp_client(hass.http.app) + return await hass_client_no_auth() @pytest.fixture(autouse=True) diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py new file mode 100644 index 00000000000..7bf62a16a42 --- /dev/null +++ b/tests/components/group/test_binary_sensor.py @@ -0,0 +1,151 @@ +"""The tests for the Group Binary Sensor platform.""" +from os import path + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.group import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass): + """Test binary_sensor group default state.""" + hass.states.async_set("binary_sensor.kitchen", "on") + hass.states.async_set("binary_sensor.bedroom", "on") + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.kitchen", "binary_sensor.bedroom"], + "name": "Bedroom Group", + "unique_id": "unique_identifier", + "device_class": "presence", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.bedroom_group") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ENTITY_ID) == [ + "binary_sensor.kitchen", + "binary_sensor.bedroom", + ] + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.bedroom_group") + assert entry + assert entry.unique_id == "unique_identifier" + assert entry.original_name == "Bedroom Group" + assert entry.device_class == "presence" + + +async def test_state_reporting_all(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.test1", "binary_sensor.test2"], + "name": "Binary Sensor Group", + "device_class": "presence", + "all": "true", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + + +async def test_state_reporting_any(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.test1", "binary_sensor.test2"], + "name": "Binary Sensor Group", + "device_class": "presence", + "all": "false", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # binary sensors have state off if unavailable + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + # binary sensors have state off if unavailable + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.binary_sensor_group") + assert entry + assert entry.unique_id == "unique_identifier" + + +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 758bc5e0dac..9d16be9150b 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -32,6 +32,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -99,7 +100,7 @@ async def setup_comp(hass, config_count): async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(COVER_GROUP) - assert state.state == STATE_CLOSED + assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME assert state.attributes[ATTR_ENTITY_ID] == [ DEMO_COVER, @@ -112,6 +113,34 @@ async def test_attributes(hass, setup_comp): assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + # Set entity as closed + hass.states.async_set(DEMO_COVER, STATE_CLOSED, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSED + + # Set entity as opening + hass.states.async_set(DEMO_COVER, STATE_OPENING, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPENING + + # Set entity as closing + hass.states.async_set(DEMO_COVER, STATE_CLOSING, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSING + + # Set entity as unknown again + hass.states.async_set(DEMO_COVER, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_UNKNOWN + # Add Entity that supports open / close / stop hass.states.async_set(DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) await hass.async_block_till_done() diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index db46ed36911..ba52e09296c 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant.components.growatt_server.const import ( CONF_PLANT_ID, DEFAULT_URL, DOMAIN, + LOGIN_INVALID_AUTH_CODE, ) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -61,7 +62,7 @@ async def test_incorrect_login(hass): with patch( "growattServer.GrowattApi.login", - return_value={"errCode": "102", "success": False}, + return_value={"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT diff --git a/tests/components/harmony/const.py b/tests/components/harmony/const.py index 488fe30dec3..9677883d25f 100644 --- a/tests/components/harmony/const.py +++ b/tests/components/harmony/const.py @@ -5,6 +5,7 @@ ENTITY_REMOTE = "remote.guest_room" ENTITY_WATCH_TV = "switch.guest_room_watch_tv" ENTITY_PLAY_MUSIC = "switch.guest_room_play_music" ENTITY_NILE_TV = "switch.guest_room_nile_tv" +ENTITY_SELECT = "select.guest_room_activities" WATCH_TV_ACTIVITY_ID = 123 PLAY_MUSIC_ACTIVITY_ID = 456 diff --git a/tests/components/harmony/test_init.py b/tests/components/harmony/test_init.py index 29a1ff26b82..d64e5b61701 100644 --- a/tests/components/harmony/test_init.py +++ b/tests/components/harmony/test_init.py @@ -7,6 +7,7 @@ from homeassistant.setup import async_setup_component from .const import ( ENTITY_NILE_TV, ENTITY_PLAY_MUSIC, + ENTITY_SELECT, ENTITY_WATCH_TV, HUB_NAME, NILE_TV_ACTIVITY_ID, @@ -55,6 +56,13 @@ async def test_unique_id_migration(mock_hc, hass, mock_write_config): platform="harmony", config_entry_id=entry.entry_id, ), + # select entity + ENTITY_SELECT: er.RegistryEntry( + entity_id=ENTITY_SELECT, + unique_id=f"{HUB_NAME}_activities", + platform="harmony", + config_entry_id=entry.entry_id, + ), }, ) assert await async_setup_component(hass, DOMAIN, {}) @@ -70,3 +78,6 @@ async def test_unique_id_migration(mock_hc, hass, mock_write_config): switch_music = ent_reg.async_get(ENTITY_PLAY_MUSIC) assert switch_music.unique_id == f"activity_{PLAY_MUSIC_ACTIVITY_ID}" + + select_activities = ent_reg.async_get(ENTITY_SELECT) + assert select_activities.unique_id == f"{HUB_NAME}_activities" diff --git a/tests/components/harmony/test_remote.py b/tests/components/harmony/test_remote.py index df75485e30d..0a176518131 100644 --- a/tests/components/harmony/test_remote.py +++ b/tests/components/harmony/test_remote.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.util import utcnow from .conftest import ACTIVITIES_TO_IDS, TV_DEVICE_ID, TV_DEVICE_NAME -from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME +from .const import ENTITY_REMOTE, HUB_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -91,10 +91,10 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - # mocks start with current activity == Watch TV - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + # mocks start remote with Watch TV default activity + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Watch TV" # turn off remote await hass.services.async_call( @@ -105,9 +105,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_OFF + assert state.attributes.get("current_activity") == "PowerOff" # turn on remote, restoring the last activity await hass.services.async_call( @@ -118,9 +118,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Watch TV" # send new activity command, with activity name await hass.services.async_call( @@ -131,9 +131,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_ON) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Play Music" # send new activity command, with activity id await hass.services.async_call( @@ -144,9 +144,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config): ) await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + state = hass.states.get(ENTITY_REMOTE) + assert state.state == STATE_ON + assert state.attributes.get("current_activity") == "Watch TV" async def test_async_send_command(mock_hc, harmony_client, hass, mock_write_config): diff --git a/tests/components/harmony/test_select.py b/tests/components/harmony/test_select.py new file mode 100644 index 00000000000..4607f035893 --- /dev/null +++ b/tests/components/harmony/test_select.py @@ -0,0 +1,113 @@ +"""Test the Logitech Harmony Hub activity select.""" + +from datetime import timedelta + +from homeassistant.components.harmony.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util import utcnow + +from .const import ENTITY_REMOTE, ENTITY_SELECT, HUB_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_connection_state_changes( + harmony_client, mock_hc, hass, mock_write_config +): + """Ensure connection changes are reflected in the switch states.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + harmony_client.mock_disconnection() + await hass.async_block_till_done() + + # Entities do not immediately show as unavailable + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + future_time = utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done() + assert hass.states.is_state(ENTITY_SELECT, STATE_UNAVAILABLE) + + harmony_client.mock_reconnection() + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + +async def test_options(mock_hc, hass, mock_write_config): + """Ensure calls to the switch modify the harmony state.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # assert we have all options + state = hass.states.get(ENTITY_SELECT) + assert state.attributes.get("options") == [ + "PowerOff", + "Nile-TV", + "Play Music", + "Watch TV", + ] + + +async def test_select_option(mock_hc, hass, mock_write_config): + """Ensure calls to the switch modify the harmony state.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_SELECT, "Watch TV") + + # launch Play Music activity + await _select_option_and_wait(hass, ENTITY_SELECT, "Play Music") + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_SELECT, "Play Music") + + # turn off harmony by selecting PowerOff activity + await _select_option_and_wait(hass, ENTITY_SELECT, "PowerOff") + assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) + assert hass.states.is_state(ENTITY_SELECT, "PowerOff") + + +async def _select_option_and_wait(hass, entity, option): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity, + ATTR_OPTION: option, + }, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 1940c54e112..d7af3680dd9 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -16,6 +16,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.helpers import entity_registry from homeassistant.util import utcnow from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME @@ -35,6 +36,17 @@ async def test_connection_state_changes( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + # check if switch entities are disabled by default + assert not hass.states.get(ENTITY_WATCH_TV) + assert not hass.states.get(ENTITY_PLAY_MUSIC) + + # enable switch entities + ent_reg = entity_registry.async_get(hass) + ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + # mocks start with current activity == Watch TV assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) @@ -78,6 +90,13 @@ async def test_switch_toggles(mock_hc, hass, mock_write_config): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + # enable switch entities + ent_reg = entity_registry.async_get(hass) + ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + # mocks start with current activity == Watch TV assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 5af9908de3a..6e62545ec68 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -287,7 +287,7 @@ async def test_warn_when_cannot_connect(hass, caplog): assert result assert hass.components.hassio.is_hassio() - assert "Not connected with Hass.io / system too busy!" in caplog.text + assert "Not connected with the supervisor / system too busy!" in caplog.text async def test_service_register(hassio_env, hass): diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 7909d8f0239..35075d79241 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -875,7 +875,7 @@ async def test_statistics_during_period( await hass.async_add_executor_job(trigger_db_commit, hass) await hass.async_block_till_done() - hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) + hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(start=now) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await hass_ws_client() @@ -886,19 +886,20 @@ async def test_statistics_during_period( "start_time": now.isoformat(), "end_time": now.isoformat(), "statistic_ids": ["sensor.test"], + "period": "hour", } ) response = await client.receive_json() assert response["success"] assert response["result"] == {} - client = await hass_ws_client() await client.send_json( { - "id": 1, + "id": 2, "type": "history/statistics_during_period", "start_time": now.isoformat(), "statistic_ids": ["sensor.test"], + "period": "5minute", } ) response = await client.receive_json() @@ -908,6 +909,7 @@ async def test_statistics_during_period( { "statistic_id": "sensor.test", "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -935,6 +937,7 @@ async def test_statistics_during_period_bad_start_time(hass, hass_ws_client): "id": 1, "type": "history/statistics_during_period", "start_time": "cats", + "period": "5minute", } ) response = await client.receive_json() @@ -961,6 +964,7 @@ async def test_statistics_during_period_bad_end_time(hass, hass_ws_client): "type": "history/statistics_during_period", "start_time": now.isoformat(), "end_time": "dogs", + "period": "5minute", } ) response = await client.receive_json() @@ -1008,7 +1012,7 @@ 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) + hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(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") diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 2852dc4fb57..1f4120115ea 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -15,7 +15,7 @@ CLIENT_SECRET = "5678" async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -48,7 +48,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/home_plus_control/test_config_flow.py b/tests/components/home_plus_control/test_config_flow.py index 4a7dbd3d3ee..5eb4115f031 100644 --- a/tests/components/home_plus_control/test_config_flow.py +++ b/tests/components/home_plus_control/test_config_flow.py @@ -20,7 +20,7 @@ from tests.components.home_plus_control.conftest import ( async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -54,7 +54,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -138,7 +138,7 @@ async def test_abort_if_entry_exists(hass, current_request_with_host): async def test_abort_if_invalid_token( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check flow abort when the token has an invalid value.""" assert await setup.async_setup_component( @@ -172,7 +172,7 @@ async def test_abort_if_invalid_token( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 3f08ca6fda2..60c7e5ac8e2 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -60,7 +60,7 @@ async def test_accessory_cancels_track_state_change_on_stop(hass, hk_driver): ): await acc.run() assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS][entity_id]) == 1 - acc.async_stop() + await acc.stop() assert entity_id not in hass.data[TRACK_STATE_CHANGE_CALLBACKS] diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index af803d50cf4..3cbe49f664b 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -33,7 +33,7 @@ def _mock_config_entry_with_options_populated(): ) -async def test_setup_in_bridge_mode(hass): +async def test_setup_in_bridge_mode(hass, mock_get_source_ip): """Test we can setup a new instance in bridge mode.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -83,7 +83,7 @@ async def test_setup_in_bridge_mode(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_setup_in_bridge_mode_name_taken(hass): +async def test_setup_in_bridge_mode_name_taken(hass, mock_get_source_ip): """Test we can setup a new instance in bridge mode when the name is taken.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -141,7 +141,9 @@ async def test_setup_in_bridge_mode_name_taken(hass): assert len(mock_setup_entry.mock_calls) == 2 -async def test_setup_creates_entries_for_accessory_mode_devices(hass): +async def test_setup_creates_entries_for_accessory_mode_devices( + hass, mock_get_source_ip +): """Test we can setup a new instance and we create entries for accessory mode devices.""" hass.states.async_set("camera.one", "on") hass.states.async_set("camera.existing", "on") @@ -231,7 +233,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): assert len(mock_setup_entry.mock_calls) == 7 -async def test_import(hass): +async def test_import(hass, mock_get_source_ip): """Test we can import instance.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -275,7 +277,7 @@ async def test_import(hass): @pytest.mark.parametrize("auto_start", [True, False]) -async def test_options_flow_exclude_mode_advanced(auto_start, hass): +async def test_options_flow_exclude_mode_advanced(auto_start, hass, mock_get_source_ip): """Test config flow options in exclude mode with advanced options.""" config_entry = _mock_config_entry_with_options_populated() @@ -326,7 +328,7 @@ async def test_options_flow_exclude_mode_advanced(auto_start, hass): } -async def test_options_flow_exclude_mode_basic(hass): +async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): """Test config flow options in exclude mode.""" config_entry = _mock_config_entry_with_options_populated() @@ -368,7 +370,7 @@ async def test_options_flow_exclude_mode_basic(hass): async def test_options_flow_devices( - mock_hap, hass, demo_cleanup, device_reg, entity_reg + mock_hap, hass, demo_cleanup, device_reg, entity_reg, mock_get_source_ip ): """Test devices can be bridged.""" config_entry = _mock_config_entry_with_options_populated() @@ -431,7 +433,9 @@ async def test_options_flow_devices( } -async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass): +async def test_options_flow_devices_preserved_when_advanced_off( + mock_hap, hass, mock_get_source_ip +): """Test devices are preserved if they were added in advanced mode but it was turned off.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -499,7 +503,7 @@ async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass): } -async def test_options_flow_include_mode_basic(hass): +async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): """Test config flow options in include mode.""" config_entry = _mock_config_entry_with_options_populated() @@ -542,7 +546,7 @@ async def test_options_flow_include_mode_basic(hass): } -async def test_options_flow_exclude_mode_with_cameras(hass): +async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): """Test config flow options in exclude mode with cameras.""" config_entry = _mock_config_entry_with_options_populated() @@ -645,7 +649,7 @@ async def test_options_flow_exclude_mode_with_cameras(hass): } -async def test_options_flow_include_mode_with_cameras(hass): +async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): """Test config flow options in include mode with cameras.""" config_entry = _mock_config_entry_with_options_populated() @@ -749,7 +753,10 @@ async def test_options_flow_include_mode_with_cameras(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "cameras" - assert result2["data_schema"]({}) == {"camera_copy": ["camera.native_h264"]} + assert result2["data_schema"]({}) == { + "camera_copy": ["camera.native_h264"], + "camera_audio": [], + } schema = result2["data_schema"].schema assert _get_schema_default(schema, "camera_copy") == ["camera.native_h264"] @@ -772,7 +779,137 @@ async def test_options_flow_include_mode_with_cameras(hass): } -async def test_options_flow_blocked_when_from_yaml(hass): +async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): + """Test config flow options with cameras that support audio.""" + + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.old", "off") + hass.states.async_set("camera.audio", "off") + hass.states.async_set("camera.no_audio", "off") + hass.states.async_set("camera.excluded", "off") + + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + 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={"domains": ["fan", "vacuum", "climate", "camera"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["camera.audio", "camera.no_audio"], + "include_exclude_mode": "include", + }, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "cameras" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_audio": ["camera.audio"]}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": ["camera.audio", "camera.no_audio"], + }, + "entity_config": {"camera.audio": {"support_audio": True}}, + } + + # Now run though again and verify we can turn off audio + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + "domains": ["fan", "vacuum", "climate", "camera"], + "mode": "bridge", + } + schema = result["data_schema"].schema + assert _get_schema_default(schema, "domains") == [ + "fan", + "vacuum", + "climate", + "camera", + ] + assert _get_schema_default(schema, "mode") == "bridge" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + assert result["data_schema"]({}) == { + "entities": ["camera.audio", "camera.no_audio"], + "include_exclude_mode": "include", + } + schema = result["data_schema"].schema + assert _get_schema_default(schema, "entities") == [ + "camera.audio", + "camera.no_audio", + ] + assert _get_schema_default(schema, "include_exclude_mode") == "include" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["climate.old", "camera.excluded"], + "include_exclude_mode": "exclude", + }, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "cameras" + assert result2["data_schema"]({}) == { + "camera_copy": [], + "camera_audio": ["camera.audio"], + } + schema = result2["data_schema"].schema + assert _get_schema_default(schema, "camera_audio") == ["camera.audio"] + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_audio": []}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "entity_config": {"camera.audio": {}}, + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old", "camera.excluded"], + "include_domains": ["fan", "vacuum", "climate", "camera"], + "include_entities": [], + }, + "mode": "bridge", + } + + +async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): """Test config flow options.""" config_entry = MockConfigEntry( @@ -812,7 +949,7 @@ async def test_options_flow_blocked_when_from_yaml(hass): assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_options_flow_include_mode_basic_accessory(hass): +async def test_options_flow_include_mode_basic_accessory(hass, mock_get_source_ip): """Test config flow options in include mode with a single accessory.""" config_entry = _mock_config_entry_with_options_populated() @@ -867,7 +1004,7 @@ async def test_options_flow_include_mode_basic_accessory(hass): } -async def test_converting_bridge_to_accessory_mode(hass, hk_driver): +async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_source_ip): """Test we can convert a bridge to accessory mode.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -947,10 +1084,17 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver): assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "cameras" - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], - user_input={"camera_copy": ["camera.tv"]}, - ) + with patch( + "homeassistant.components.homekit.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.homekit.async_port_is_available" + ): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_copy": ["camera.tv"]}, + ) + await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { @@ -964,6 +1108,7 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver): "include_entities": ["camera.tv"], }, } + assert len(mock_setup_entry.mock_calls) == 1 def _get_schema_default(schema, key_name): diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index af98f6a45f9..be2429c79cf 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -274,6 +274,8 @@ def test_type_sensors(type_name, entity_id, state, attrs): ("Switch", "remote.test", "on", {}, {}), ("Switch", "scene.test", "on", {}, {}), ("Switch", "script.test", "on", {}, {}), + ("SelectSwitch", "input_select.test", "option1", {}, {}), + ("SelectSwitch", "select.test", "option1", {}, {}), ("Switch", "switch.test", "on", {}, {}), ("Switch", "switch.test", "on", {}, {CONF_TYPE: TYPE_SWITCH}), ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_FAUCET}), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 4976985fa15..039bd1c11c3 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -102,6 +102,13 @@ def always_patch_driver(hk_driver): """Load the hk_driver fixture.""" +@pytest.fixture(autouse=True) +def patch_source_ip(mock_get_source_ip): + """Patch homeassistant and pyhap functions for getting local address.""" + with patch("pyhap.util.get_local_address", return_value="10.10.10.10"): + yield + + def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): return HomeKit( hass=hass, @@ -258,8 +265,9 @@ 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() + uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, zeroconf_mock) + await hass.async_add_executor_job(homekit.setup, zeroconf_mock, uuid) path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) mock_driver.assert_called_with( @@ -273,6 +281,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): persist_file=path, advertised_address=None, async_zeroconf_instance=zeroconf_mock, + zeroconf_server=f"{uuid}-hap.local.", ) assert homekit.driver.safe_mode is False @@ -300,8 +309,9 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): mock_zeroconf = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) + uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, mock_zeroconf) + await hass.async_add_executor_job(homekit.setup, mock_zeroconf, uuid) mock_driver.assert_called_with( hass, entry.entry_id, @@ -313,6 +323,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): persist_file=path, advertised_address=None, async_zeroconf_instance=mock_zeroconf, + zeroconf_server=f"{uuid}-hap.local.", ) @@ -339,8 +350,9 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): async_zeroconf_instance = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) + uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance) + await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance, uuid) mock_driver.assert_called_with( hass, entry.entry_id, @@ -352,6 +364,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): persist_file=path, advertised_address="192.168.1.100", async_zeroconf_instance=async_zeroconf_instance, + zeroconf_server=f"{uuid}-hap.local.", ) @@ -429,11 +442,12 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): homekit.driver = "driver" homekit.bridge = _mock_pyhap_bridge() acc_mock = MagicMock() + acc_mock.stop = AsyncMock() homekit.bridge.accessories = {6: acc_mock} - acc = homekit.remove_bridge_accessory(6) + acc = await homekit.async_remove_bridge_accessory(6) assert acc is acc_mock - assert acc_mock.async_stop.called + assert acc_mock.stop.called assert len(homekit.bridge.accessories) == 0 @@ -682,9 +696,11 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -717,9 +733,12 @@ async def test_homekit_unpair(hass, device_reg, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() state = homekit.driver.state state.add_paired_client("client1", "any", b"1") @@ -756,9 +775,12 @@ async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf) acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() state = homekit.driver.state state.add_paired_client("client1", "any", b"1") @@ -794,6 +816,8 @@ async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING @@ -843,9 +867,12 @@ async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -883,9 +910,12 @@ async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -922,9 +952,12 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -961,7 +994,10 @@ async def test_homekit_reset_single_accessory(hass, mock_zeroconf): homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -995,7 +1031,10 @@ async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -1028,7 +1067,10 @@ async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf) homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -1061,7 +1103,10 @@ async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): homekit.status = STATUS_RUNNING acc_mock = MagicMock() acc_mock.entity_id = entity_id + acc_mock.stop = AsyncMock() + homekit.driver.accessory = acc_mock + homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( DOMAIN, @@ -1301,7 +1346,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" - ): + ), patch(f"{PATH_HOMEKIT}.async_port_is_available"): entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -1694,7 +1739,7 @@ async def test_wait_for_port_to_free(hass, hk_driver, mock_zeroconf, caplog): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" - ), patch(f"{PATH_HOMEKIT}.port_is_available", return_value=True) as port_mock: + ), patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) as port_mock: assert 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) @@ -1705,7 +1750,7 @@ async def test_wait_for_port_to_free(hass, hk_driver, mock_zeroconf, caplog): with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" ), patch.object(homekit_base, "PORT_CLEANUP_CHECK_INTERVAL_SECS", 0), patch( - f"{PATH_HOMEKIT}.port_is_available", return_value=False + f"{PATH_HOMEKIT}.async_port_is_available", return_value=False ) as port_mock: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 6643ae9ae18..8652f8b032a 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -14,7 +14,7 @@ from homeassistant.setup import async_setup_component from tests.components.logbook.test_init import MockLazyEventPartialState -async def test_humanify_homekit_changed_event(hass, hk_driver): +async def test_humanify_homekit_changed_event(hass, hk_driver, mock_get_source_ip): """Test humanifying HomeKit changed event.""" hass.config.components.add("recorder") with patch("homeassistant.components.homekit.HomeKit"): diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 991965b30b5..05c809910cf 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -1,5 +1,6 @@ """Test different accessory types: Camera.""" +import asyncio from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from uuid import UUID @@ -45,6 +46,7 @@ PID_THAT_WILL_NEVER_BE_ALIVE = 2147483647 async def _async_start_streaming(hass, acc): """Start streaming a camera.""" acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV) + await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() @@ -92,6 +94,18 @@ def run_driver(hass): ) +def _mock_reader(): + """Mock ffmpeg reader.""" + + async def _readline(*args, **kwargs): + await asyncio.sleep(0.1) + + async def _get_reader(*args, **kwargs): + return AsyncMock(readline=_readline) + + return _get_reader + + def _get_exits_after_startup_mock_ffmpeg(): """Return a ffmpeg that will have an invalid pid.""" ffmpeg = MagicMock() @@ -99,7 +113,7 @@ def _get_exits_after_startup_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=True) ffmpeg.close = AsyncMock(return_value=True) ffmpeg.kill = AsyncMock(return_value=True) - ffmpeg.get_reader = AsyncMock() + ffmpeg.get_reader = _mock_reader() return ffmpeg @@ -109,7 +123,7 @@ def _get_working_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=True) ffmpeg.close = AsyncMock(return_value=True) ffmpeg.kill = AsyncMock(return_value=True) - ffmpeg.get_reader = AsyncMock() + ffmpeg.get_reader = _mock_reader() return ffmpeg @@ -120,7 +134,7 @@ def _get_failing_mock_ffmpeg(): ffmpeg.open = AsyncMock(return_value=False) ffmpeg.close = AsyncMock(side_effect=OSError) ffmpeg.kill = AsyncMock(side_effect=OSError) - ffmpeg.get_reader = AsyncMock() + ffmpeg.get_reader = _mock_reader() return ffmpeg @@ -317,29 +331,59 @@ async def test_camera_stream_source_found(hass, run_driver, events): assert acc.category == 17 # Camera await _async_setup_endpoints(hass, acc) + working_ffmpeg = _get_working_mock_ffmpeg() + session_info = acc.sessions[MOCK_START_STREAM_SESSION_UUID] with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="rtsp://example.local", ), patch( "homeassistant.components.homekit.type_cameras.HAFFmpeg", - return_value=_get_working_mock_ffmpeg(), + return_value=working_ffmpeg, ): await _async_start_streaming(hass, acc) await _async_stop_all_streams(hass, acc) + expected_output = ( + "-map 0:v:0 -an -c:v libx264 -profile:v high -tune zerolatency -pix_fmt " + "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " + "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " + "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316" + ) + + working_ffmpeg.open.assert_called_with( + cmd=[], + input_source="-i rtsp://example.local", + output=expected_output.format(**session_info), + stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, + ) + await _async_setup_endpoints(hass, acc) + working_ffmpeg = _get_working_mock_ffmpeg() + session_info = acc.sessions[MOCK_START_STREAM_SESSION_UUID] with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", - return_value="rtsp://example.local", + return_value="rtsp://example2.local", ), patch( "homeassistant.components.homekit.type_cameras.HAFFmpeg", - return_value=_get_working_mock_ffmpeg(), + return_value=working_ffmpeg, ): await _async_start_streaming(hass, acc) await _async_stop_all_streams(hass, acc) + working_ffmpeg.open.assert_called_with( + cmd=[], + input_source="-i rtsp://example2.local", + output=expected_output.format(**session_info), + stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, + ) + async def test_camera_stream_source_fails(hass, run_driver, events): """Test a camera that can stream and we cannot get the source from the entity.""" diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 6df1f0182ed..c13f7ea2538 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -10,7 +10,14 @@ from homeassistant.components.homekit.const import ( TYPE_SPRINKLER, TYPE_VALVE, ) -from homeassistant.components.homekit.type_switches import Outlet, Switch, Vacuum, Valve +from homeassistant.components.homekit.type_switches import ( + Outlet, + SelectSwitch, + Switch, + Vacuum, + Valve, +) +from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -26,6 +33,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_TYPE, + SERVICE_SELECT_OPTION, STATE_OFF, STATE_ON, ) @@ -387,3 +395,57 @@ async def test_script_switch(hass, hk_driver, events): await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 + + +@pytest.mark.parametrize( + "domain", + ["input_select", "select"], +) +async def test_input_select_switch(hass, hk_driver, events, domain): + """Test if select switch accessory is handled correctly.""" + entity_id = f"{domain}.test" + + hass.states.async_set( + entity_id, "option1", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + acc = SelectSwitch(hass, hk_driver, "SelectSwitch", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.select_chars["option1"].value is True + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is False + + call_select_option = async_mock_service(hass, domain, SERVICE_SELECT_OPTION) + acc.select_chars["option2"].client_update_value(True) + await hass.async_block_till_done() + + assert call_select_option + assert call_select_option[0].data == {"entity_id": entity_id, "option": "option2"} + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + hass.states.async_set( + entity_id, "option2", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is True + assert acc.select_chars["option3"].value is False + + hass.states.async_set( + entity_id, "option3", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is True + + hass.states.async_set( + entity_id, "invalid", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is False diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 33c5c8623d1..94936e3e2c2 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -1,5 +1,5 @@ """Test HomeKit util module.""" -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock, patch import pytest import voluptuous as vol @@ -26,12 +26,12 @@ from homeassistant.components.homekit.const import ( from homeassistant.components.homekit.util import ( accessory_friendly_name, async_find_next_available_port, + async_port_is_available, cleanup_name_for_homekit, convert_to_float, density_to_air_quality, dismiss_setup_message, format_sw_version, - port_is_available, show_setup_message, state_needs_accessory_mode, temperature_to_homekit, @@ -61,6 +61,22 @@ from .util import async_init_integration from tests.common import MockConfigEntry, async_mock_service +def _mock_socket(failure_attempts: int = 0) -> MagicMock: + """Mock a socket that fails to bind failure_attempts amount of times.""" + mock_socket = MagicMock() + attempts = 0 + + def _simulate_bind(*_): + nonlocal attempts + attempts += 1 + if attempts <= failure_attempts: + raise OSError + return + + mock_socket.bind = Mock(side_effect=_simulate_bind) + return mock_socket + + def test_validate_entity_config(): """Test validate entities.""" configs = [ @@ -219,7 +235,7 @@ def test_density_to_air_quality(): assert density_to_air_quality(300) == 5 -async def test_show_setup_msg(hass, hk_driver): +async def test_show_setup_msg(hass, hk_driver, mock_get_source_ip): """Test show setup message as persistence notification.""" pincode = b"123-45-678" @@ -257,11 +273,35 @@ async def test_dismiss_setup_msg(hass): async def test_port_is_available(hass): """Test we can get an available port and it is actually available.""" - next_port = await async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) - + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(0), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) assert next_port + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(0), + ): + assert async_port_is_available(next_port) - assert await hass.async_add_executor_job(port_is_available, next_port) + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(5), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + assert next_port == DEFAULT_CONFIG_FLOW_PORT + 5 + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(0), + ): + assert async_port_is_available(next_port) + + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(1), + ): + assert not async_port_is_available(next_port) async def test_port_is_available_skips_existing_entries(hass): @@ -273,12 +313,38 @@ async def test_port_is_available_skips_existing_entries(hass): ) entry.add_to_hass(hass) - next_port = await async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) - assert next_port - assert next_port != DEFAULT_CONFIG_FLOW_PORT + assert next_port == DEFAULT_CONFIG_FLOW_PORT + 1 - assert await hass.async_add_executor_job(port_is_available, next_port) + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(), + ): + assert async_port_is_available(next_port) + + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(4), + ): + next_port = async_find_next_available_port(hass, DEFAULT_CONFIG_FLOW_PORT) + + assert next_port == DEFAULT_CONFIG_FLOW_PORT + 5 + with patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(), + ): + assert async_port_is_available(next_port) + + with pytest.raises(OSError), patch( + "homeassistant.components.homekit.util.socket.socket", + return_value=_mock_socket(10), + ): + async_find_next_available_port(hass, 65530) async def test_format_sw_version(): diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 266fa177fb2..dc27162bc57 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -11,13 +11,6 @@ import homeassistant.util.dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa: F401 -@pytest.fixture(autouse=True) -def mock_zeroconf(): - """Mock zeroconf.""" - with mock.patch("homeassistant.components.zeroconf.models.HaZeroconf") as mock_zc: - yield mock_zc.return_value - - @pytest.fixture def utcnow(request): """Freeze time at a known point.""" @@ -34,3 +27,8 @@ def controller(hass): instance = FakeController() with unittest.mock.patch("aiohomekit.Controller", return_value=instance): yield instance + + +@pytest.fixture(autouse=True) +def homekit_mock_zeroconf(mock_zeroconf): + """Mock zeroconf in all homekit tests.""" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 6bd1d622b12..71d848d12ab 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -36,7 +36,7 @@ async def mock_handler(request): user = request.get("hass_user") user_id = user.id if user else None - return web.json_response(status=200, data={"user_id": user_id}) + return web.json_response(data={"user_id": user_id}) async def get_legacy_user(auth): diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 04447191fd5..d03b40b2df3 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -44,7 +44,7 @@ async def test_cors_middleware_loaded_from_config(hass): async def mock_handler(request): """Return if request was authenticated.""" - return web.Response(status=200) + return web.Response() @pytest.fixture diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 65f01118c71..446b6c218bb 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -64,10 +64,10 @@ async def test_registering_view_while_running( hass.http.register_view(TestView) -async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): +async def test_not_log_password(hass, hass_client_no_auth, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, "api", {"http": {}}) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() logging.getLogger("aiohttp.access").setLevel(logging.INFO) resp = await client.get("/api/", params={"api_password": "test-password"}) diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 3deec0988fa..2c79795d48b 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -681,3 +681,57 @@ def _get_schema_default(schema, key_name): if schema_key == key_name: return schema_key.default() raise KeyError(f"{key_name} not found in schema") + + +async def test_bridge_zeroconf(hass): + """Test a bridge being discovered.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": "192.168.1.217", + "port": 443, + "hostname": "Philips-hue.local.", + "type": "_hue._tcp.local.", + "name": "Philips Hue - ABCABC._hue._tcp.local.", + "properties": { + "_raw": {"bridgeid": b"ecb5fafffeabcabc", "modelid": b"BSB002"}, + "bridgeid": "ecb5fafffeabcabc", + "modelid": "BSB002", + }, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "link" + + +async def test_bridge_zeroconf_already_exists(hass): + """Test a bridge being discovered by zeroconf already exists.""" + entry = MockConfigEntry( + domain="hue", + source=config_entries.SOURCE_SSDP, + data={"host": "0.0.0.0"}, + unique_id="ecb5faabcabc", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": "192.168.1.217", + "port": 443, + "hostname": "Philips-hue.local.", + "type": "_hue._tcp.local.", + "name": "Philips Hue - ABCABC._hue._tcp.local.", + "properties": { + "_raw": {"bridgeid": b"ecb5faabcabc", "modelid": b"BSB002"}, + "bridgeid": "ecb5faabcabc", + "modelid": "BSB002", + }, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "192.168.1.217" diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 077fb6d7470..1ca8395e11f 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -5,7 +5,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import callback -async def test_config_flow_registers_webhook(hass, aiohttp_client): +async def test_config_flow_registers_webhook(hass, hass_client_no_auth): """Test setting up IFTTT and sending webhook.""" await async_process_ha_core_config( hass, @@ -30,7 +30,7 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): hass.bus.async_listen(ifttt.EVENT_RECEIVED, handle_event) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", json={"hello": "ifttt"}) assert len(ifttt_events) == 1 diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 55c76273ad7..c0c57b17a7c 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -4,289 +4,231 @@ from unittest.mock import PropertyMock, patch import homeassistant.components.http as http import homeassistant.components.image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import DATA_CUSTOM_COMPONENTS -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_test_home_assistant, - get_test_instance_port, -) +from tests.common import assert_setup_component, async_capture_events from tests.components.image_processing import common -class TestSetupImageProcessing: - """Test class for setup image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Set up demo platform on image_process component.""" - config = {ip.DOMAIN: {"platform": "demo"}} - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - - def test_setup_component_with_service(self): - """Set up demo platform on image_process component test service.""" - config = {ip.DOMAIN: {"platform": "demo"}} - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - - assert self.hass.services.has_service(ip.DOMAIN, "scan") +def get_url(hass): + """Return camera url.""" + state = hass.states.get("camera.demo_camera") + return f"{hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" -class TestImageProcessing: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.data.pop(DATA_CUSTOM_COMPONENTS) - - setup_component( - self.hass, - http.DOMAIN, - {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}}, - ) - - config = {ip.DOMAIN: {"platform": "test"}, "camera": {"platform": "demo"}} - - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - @patch( - "homeassistant.components.demo.camera.Path.read_bytes", - return_value=b"Test", +async def setup_image_processing(hass, aiohttp_unused_port): + """Set up things to be run when tests are started.""" + await async_setup_component( + hass, + http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: aiohttp_unused_port()}}, ) - def test_get_image_from_camera(self, mock_camera_read): - """Grab an image from camera entity.""" - common.scan(self.hass, entity_id="image_processing.test") - self.hass.block_till_done() - state = self.hass.states.get("image_processing.test") + config = {ip.DOMAIN: {"platform": "test"}, "camera": {"platform": "demo"}} - assert mock_camera_read.called - assert state.state == "1" - assert state.attributes["image"] == b"Test" - - @patch( - "homeassistant.components.camera.async_get_image", - side_effect=HomeAssistantError(), - ) - def test_get_image_without_exists_camera(self, mock_image): - """Try to get image without exists camera.""" - self.hass.states.remove("camera.demo_camera") - - common.scan(self.hass, entity_id="image_processing.test") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test") - - assert mock_image.called - assert state.state == "0" + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() -class TestImageProcessingAlpr: - """Test class for alpr image processing.""" +async def setup_image_processing_alpr(hass): + """Set up things to be run when tests are started.""" + config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() - config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} - - with patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingAlpr.should_poll", - new_callable=PropertyMock(return_value=False), - ): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - self.alpr_events = [] - - @callback - def mock_alpr_event(event): - """Mock event.""" - self.alpr_events.append(event) - - self.hass.bus.listen("image_processing.found_plate", mock_alpr_event) - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_alpr_event_single_call(self, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.demo_alpr") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.demo_alpr") - - assert len(self.alpr_events) == 4 - assert state.state == "AC3829" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "AC3829" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "AC3829" - assert event_data[0]["confidence"] == 98.3 - assert event_data[0]["entity_id"] == "image_processing.demo_alpr" - - def test_alpr_event_double_call(self, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.demo_alpr") - common.scan(self.hass, entity_id="image_processing.demo_alpr") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.demo_alpr") - - assert len(self.alpr_events) == 4 - assert state.state == "AC3829" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "AC3829" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "AC3829" - assert event_data[0]["confidence"] == 98.3 - assert event_data[0]["entity_id"] == "image_processing.demo_alpr" - - @patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingAlpr.confidence", - new_callable=PropertyMock(return_value=95), - ) - def test_alpr_event_single_call_confidence(self, confidence_mock, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.demo_alpr") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.demo_alpr") - - assert len(self.alpr_events) == 2 - assert state.state == "AC3829" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "AC3829" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "AC3829" - assert event_data[0]["confidence"] == 98.3 - assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + return async_capture_events(hass, "image_processing.found_plate") -class TestImageProcessingFace: - """Test class for face image processing.""" +async def setup_image_processing_face(hass): + """Set up things to be run when tests are started.""" + config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() - config = {ip.DOMAIN: {"platform": "demo"}, "camera": {"platform": "demo"}} + return async_capture_events(hass, "image_processing.detect_face") - with patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingFace.should_poll", - new_callable=PropertyMock(return_value=False), - ): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" +async def test_setup_component(hass): + """Set up demo platform on image_process component.""" + config = {ip.DOMAIN: {"platform": "demo"}} - self.face_events = [] + with assert_setup_component(1, ip.DOMAIN): + assert await async_setup_component(hass, ip.DOMAIN, config) - @callback - def mock_face_event(event): - """Mock event.""" - self.face_events.append(event) - self.hass.bus.listen("image_processing.detect_face", mock_face_event) +async def test_setup_component_with_service(hass): + """Set up demo platform on image_process component test service.""" + config = {ip.DOMAIN: {"platform": "demo"}} - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() + with assert_setup_component(1, ip.DOMAIN): + assert await async_setup_component(hass, ip.DOMAIN, config) - def test_face_event_call(self, aioclient_mock): - """Set up and scan a picture and test faces from event.""" - aioclient_mock.get(self.url, content=b"image") + assert hass.services.has_service(ip.DOMAIN, "scan") - common.scan(self.hass, entity_id="image_processing.demo_face") - self.hass.block_till_done() - state = self.hass.states.get("image_processing.demo_face") +@patch( + "homeassistant.components.demo.camera.Path.read_bytes", + return_value=b"Test", +) +async def test_get_image_from_camera( + mock_camera_read, hass, aiohttp_unused_port, enable_custom_integrations +): + """Grab an image from camera entity.""" + await setup_image_processing(hass, aiohttp_unused_port) - assert len(self.face_events) == 2 - assert state.state == "Hans" - assert state.attributes["total_faces"] == 4 + common.async_scan(hass, entity_id="image_processing.test") + await hass.async_block_till_done() - event_data = [ - event.data for event in self.face_events if event.data.get("name") == "Hans" - ] - assert len(event_data) == 1 - assert event_data[0]["name"] == "Hans" - assert event_data[0]["confidence"] == 98.34 - assert event_data[0]["gender"] == "male" - assert event_data[0]["entity_id"] == "image_processing.demo_face" + state = hass.states.get("image_processing.test") - @patch( - "homeassistant.components.demo.image_processing." - "DemoImageProcessingFace.confidence", - new_callable=PropertyMock(return_value=None), - ) - def test_face_event_call_no_confidence(self, mock_config, aioclient_mock): - """Set up and scan a picture and test faces from event.""" - aioclient_mock.get(self.url, content=b"image") + assert mock_camera_read.called + assert state.state == "1" + assert state.attributes["image"] == b"Test" - common.scan(self.hass, entity_id="image_processing.demo_face") - self.hass.block_till_done() - state = self.hass.states.get("image_processing.demo_face") +@patch( + "homeassistant.components.camera.async_get_image", + side_effect=HomeAssistantError(), +) +async def test_get_image_without_exists_camera( + mock_image, hass, aiohttp_unused_port, enable_custom_integrations +): + """Try to get image without exists camera.""" + await setup_image_processing(hass, aiohttp_unused_port) - assert len(self.face_events) == 3 - assert state.state == "4" - assert state.attributes["total_faces"] == 4 + hass.states.async_remove("camera.demo_camera") - event_data = [ - event.data for event in self.face_events if event.data.get("name") == "Hans" - ] - assert len(event_data) == 1 - assert event_data[0]["name"] == "Hans" - assert event_data[0]["confidence"] == 98.34 - assert event_data[0]["gender"] == "male" - assert event_data[0]["entity_id"] == "image_processing.demo_face" + common.async_scan(hass, entity_id="image_processing.test") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test") + + assert mock_image.called + assert state.state == "0" + + +async def test_alpr_event_single_call(hass, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + alpr_events = await setup_image_processing_alpr(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_alpr") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_alpr") + + assert len(alpr_events) == 4 + assert state.state == "AC3829" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "AC3829" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "AC3829" + assert event_data[0]["confidence"] == 98.3 + assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + + +async def test_alpr_event_double_call(hass, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + alpr_events = await setup_image_processing_alpr(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_alpr") + common.async_scan(hass, entity_id="image_processing.demo_alpr") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_alpr") + + assert len(alpr_events) == 4 + assert state.state == "AC3829" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "AC3829" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "AC3829" + assert event_data[0]["confidence"] == 98.3 + assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + + +@patch( + "homeassistant.components.demo.image_processing.DemoImageProcessingAlpr.confidence", + new_callable=PropertyMock(return_value=95), +) +async def test_alpr_event_single_call_confidence(confidence_mock, hass, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + alpr_events = await setup_image_processing_alpr(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_alpr") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_alpr") + + assert len(alpr_events) == 2 + assert state.state == "AC3829" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "AC3829" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "AC3829" + assert event_data[0]["confidence"] == 98.3 + assert event_data[0]["entity_id"] == "image_processing.demo_alpr" + + +async def test_face_event_call(hass, aioclient_mock): + """Set up and scan a picture and test faces from event.""" + face_events = await setup_image_processing_face(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_face") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_face") + + assert len(face_events) == 2 + assert state.state == "Hans" + assert state.attributes["total_faces"] == 4 + + event_data = [ + event.data for event in face_events if event.data.get("name") == "Hans" + ] + assert len(event_data) == 1 + assert event_data[0]["name"] == "Hans" + assert event_data[0]["confidence"] == 98.34 + assert event_data[0]["gender"] == "male" + assert event_data[0]["entity_id"] == "image_processing.demo_face" + + +@patch( + "homeassistant.components.demo.image_processing." + "DemoImageProcessingFace.confidence", + new_callable=PropertyMock(return_value=None), +) +async def test_face_event_call_no_confidence(mock_config, hass, aioclient_mock): + """Set up and scan a picture and test faces from event.""" + face_events = await setup_image_processing_face(hass) + aioclient_mock.get(get_url(hass), content=b"image") + + common.async_scan(hass, entity_id="image_processing.demo_face") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.demo_face") + + assert len(face_events) == 3 + assert state.state == "4" + assert state.attributes["total_faces"] == 4 + + event_data = [ + event.data for event in face_events if event.data.get("name") == "Hans" + ] + assert len(event_data) == 1 + assert event_data[0]["name"] == "Hans" + assert event_data[0]["confidence"] == 98.34 + assert event_data[0]["gender"] == "male" + assert event_data[0]["entity_id"] == "image_processing.demo_face" diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 58df0a53a00..03a43fd2c66 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING +from homeassistant.components.sensor import STATE_CLASS_TOTAL from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -39,7 +39,7 @@ async def test_state(hass) -> None: state = hass.states.get("sensor.integration") assert state is not None - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL assert "device_class" not in state.attributes future_now = dt_util.utcnow() + timedelta(seconds=3600) @@ -57,7 +57,7 @@ async def test_state(hass) -> None: 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_TOTAL_INCREASING + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL async def test_restore_state(hass: HomeAssistant) -> None: @@ -103,7 +103,9 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: State( "sensor.integration", "INVALID", - {}, + { + "last_reset": "2019-10-06T21:00:00.000000", + }, ), ), ) @@ -121,9 +123,9 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: state = hass.states.get("sensor.integration") assert state - assert state.state == "unavailable" + assert state.state == "unknown" assert state.attributes.get("unit_of_measurement") is None - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL assert "device_class" not in state.attributes diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py index 3d1afe1b88b..07ea6dfc15c 100644 --- a/tests/components/iotawatt/__init__.py +++ b/tests/components/iotawatt/__init__.py @@ -3,19 +3,46 @@ from iotawattpy.sensor import Sensor INPUT_SENSOR = Sensor( channel="1", - name="My Sensor", + base_name="My Sensor", + suffix=None, io_type="Input", - unit="WattHours", - value="23", + unit="Watts", + value=23, begin="", mac_addr="mock-mac", ) OUTPUT_SENSOR = Sensor( channel="N/A", - name="My WattHour Sensor", + base_name="My WattHour Sensor", + suffix=None, io_type="Output", unit="WattHours", - value="243", + value=243, begin="", mac_addr="mock-mac", + fromStart=True, +) + +INPUT_ACCUMULATED_SENSOR = Sensor( + channel="N/A", + base_name="My WattHour Accumulated Input Sensor", + suffix=".wh", + io_type="Input", + unit="WattHours", + value=500, + begin="", + mac_addr="mock-mac", + fromStart=False, +) + +OUTPUT_ACCUMULATED_SENSOR = Sensor( + channel="N/A", + base_name="My WattHour Accumulated Output Sensor", + suffix=".wh", + io_type="Output", + unit="WattHours", + value=200, + begin="", + mac_addr="mock-mac", + fromStart=False, ) diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index a5fc2250b84..2397338c22c 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,19 +1,33 @@ """Test setting up sensors.""" from datetime import timedelta -from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY +from homeassistant.components.iotawatt.const import ATTR_LAST_UPDATE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, + POWER_WATT, ) +from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import INPUT_SENSOR, OUTPUT_SENSOR +from . import ( + INPUT_ACCUMULATED_SENSOR, + INPUT_SENSOR, + OUTPUT_ACCUMULATED_SENSOR, + OUTPUT_SENSOR, +) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_sensor_type_input(hass, mock_iotawatt): @@ -33,10 +47,10 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER assert state.attributes["channel"] == "1" assert state.attributes["type"] == "Input" @@ -60,6 +74,7 @@ async def test_sensor_type_output(hass, mock_iotawatt): state = hass.states.get("sensor.my_watthour_sensor") assert state is not None assert state.state == "243" + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY @@ -70,3 +85,161 @@ async def test_sensor_type_output(hass, mock_iotawatt): await hass.async_block_till_done() assert hass.states.get("sensor.my_watthour_sensor") is None + + +async def test_sensor_type_accumulated_output(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "100.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "300.0" # 100 + 200 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + +async def test_sensor_type_accumulated_output_error_restore(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "unknown", + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "200.0" # Returns the new read as restore failed. + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + +async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): + """Tests the sensor type of Accumulated Output and that it's properly restored from saved state.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_output_sensor_key" + ] = OUTPUT_ACCUMULATED_SENSOR + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_accumulated_input_sensor_key" + ] = INPUT_ACCUMULATED_SENSOR + + DUMMY_DATE = "2021-09-01T14:00:00+10:00" + + mock_restore_cache( + hass, + ( + State( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", + "100.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + State( + "sensor.my_watthour_accumulated_input_sensor_wh_accumulated", + "50.0", + { + "device_class": DEVICE_CLASS_ENERGY, + "unit_of_measurement": ENERGY_WATT_HOUR, + "last_update": DUMMY_DATE, + }, + ), + ), + ) + + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 2 + + state = hass.states.get( + "sensor.my_watthour_accumulated_output_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "300.0" # 100 + 200 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Output Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE + + state = hass.states.get( + "sensor.my_watthour_accumulated_input_sensor_wh_accumulated" + ) + assert state is not None + + assert state.state == "550.0" # 50 + 500 + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == "My WattHour Accumulated Input Sensor.wh Accumulated" + ) + assert state.attributes[ATTR_LAST_UPDATE] is not None + assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 1e96de9ff2f..09e6e26e777 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -383,12 +383,7 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant): ) 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: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -404,9 +399,6 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant): 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.""" @@ -418,12 +410,7 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): ) 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: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -439,9 +426,6 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): 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.""" @@ -453,12 +437,7 @@ async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant) ) 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: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -474,9 +453,6 @@ async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant) 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.""" @@ -488,12 +464,7 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): ) 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: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -509,9 +480,6 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): 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.""" @@ -560,12 +528,7 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant): ) 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: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, @@ -581,9 +544,6 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant): 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.""" @@ -598,12 +558,7 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): ) 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: + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, @@ -619,6 +574,3 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): 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/kraken/const.py b/tests/components/kraken/const.py index 6e3174a9ae7..78658ffd660 100644 --- a/tests/components/kraken/const.py +++ b/tests/components/kraken/const.py @@ -7,6 +7,12 @@ TRADEABLE_ASSET_PAIR_RESPONSE = pandas.DataFrame( index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZUSD", "XXBTZJPY"], ) +MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE = pandas.DataFrame( + {"wsname": ["ADA/XBT", "ADA/ETH", "XBT/EUR", "XBT/GBP", "XBT/JPY"]}, + columns=["wsname"], + index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZJPY"], +) + TICKER_INFORMATION_RESPONSE = pandas.DataFrame( { "a": [ @@ -78,3 +84,67 @@ TICKER_INFORMATION_RESPONSE = pandas.DataFrame( columns=["a", "b", "c", "h", "l", "o", "p", "t", "v"], index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZUSD", "XXBTZJPY"], ) + +MISSING_PAIR_TICKER_INFORMATION_RESPONSE = pandas.DataFrame( + { + "a": [ + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + [0.000349400, 15949, 15949.000], + ], + "b": [ + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + [0.000348400, 20792, 20792.000], + ], + "c": [ + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + [0.000347800, 2809.36384377], + ], + "h": [ + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + [0.000351600, 0.000352100], + ], + "l": [ + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + [0.000344600, 0.000344600], + ], + "o": [ + 0.000351300, + 0.000351300, + 0.000351300, + 0.000351300, + 0.000351300, + ], + "p": [ + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + [0.000348573, 0.000344881], + ], + "t": [[82, 128], [82, 128], [82, 128], [82, 128], [82, 128]], + "v": [ + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + [146300.24906838, 253478.04715403], + ], + }, + columns=["a", "b", "c", "h", "l", "o", "p", "t", "v"], + index=["ADAXBT", "ADAETH", "XBTEUR", "XXBTZGBP", "XXBTZJPY"], +) diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 98760a3002d..110a944a4d5 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.kraken.const import ( from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START import homeassistant.util.dt as dt_util -from .const import TICKER_INFORMATION_RESPONSE, TRADEABLE_ASSET_PAIR_RESPONSE +from .const import ( + MISSING_PAIR_TICKER_INFORMATION_RESPONSE, + MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE, + TICKER_INFORMATION_RESPONSE, + TRADEABLE_ASSET_PAIR_RESPONSE, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -230,38 +235,46 @@ async def test_missing_pair_marks_sensor_unavailable(hass): with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, - ): - with patch( - "pykrakenapi.KrakenAPI.get_ticker_information", - return_value=TICKER_INFORMATION_RESPONSE, - ): - entry = MockConfigEntry( - domain=DOMAIN, - options={ - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], - }, - ) - entry.add_to_hass(hass) + ) as tradeable_asset_pairs_mock, patch( + "pykrakenapi.KrakenAPI.get_ticker_information", + return_value=TICKER_INFORMATION_RESPONSE, + ) as ticket_information_mock: + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], + }, + ) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - sensor = hass.states.get("sensor.xbt_usd_ask") - assert sensor.state == "0.0003494" + sensor = hass.states.get("sensor.xbt_usd_ask") + assert sensor.state == "0.0003494" - with patch( - "pykrakenapi.KrakenAPI.get_ticker_information", - side_effect=KrakenAPIError("EQuery:Unknown asset pair"), - ): - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) - await hass.async_block_till_done() + tradeable_asset_pairs_mock.return_value = ( + MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE + ) + ticket_information_mock.side_effect = KrakenAPIError( + "EQuery:Unknown asset pair" + ) + async_fire_time_changed( + hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) + ) + await hass.async_block_till_done() - sensor = hass.states.get("sensor.xbt_usd_ask") - assert sensor.state == "unavailable" + ticket_information_mock.side_effect = None + ticket_information_mock.return_value = MISSING_PAIR_TICKER_INFORMATION_RESPONSE + async_fire_time_changed( + hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) + ) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.xbt_usd_ask") + assert sensor.state == "unavailable" diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index af7a177edb8..cbb37f94dc4 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -9,8 +9,16 @@ from homeassistant.components.lastfm.sensor import STATE_NOT_SCROBBLING from homeassistant.setup import async_setup_component +class MockNetwork: + """Mock _Network object for pylast.""" + + def __init__(self, username: str): + """Initialize the mock.""" + self.username = username + + class MockUser: - """Mock user object for pylast.""" + """Mock User object for pylast.""" def __init__(self, now_playing_result): """Initialize the mock.""" @@ -67,7 +75,7 @@ async def test_update_playing(hass, lastfm_network): """Test update when song playing.""" lastfm_network.return_value.get_user.return_value = MockUser( - Track("artist", "title", None) + Track("artist", "title", MockNetwork("test")) ) assert await async_setup_component( diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index f3cf3ccce39..2db5b827838 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -2,13 +2,23 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +import pytest + from homeassistant.components import alarm_control_panel from homeassistant.components.demo import alarm_control_panel as demo from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, @@ -32,8 +42,18 @@ async def test_setup_demo_platform(hass): assert add_entities.call_count == 1 -async def test_arm_home_no_pending(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_no_pending(hass, service, expected_state): + """Test no pending after arming.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -53,13 +73,28 @@ async def test_arm_home_no_pending(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, CODE) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME + assert hass.states.get(entity_id).state == expected_state -async def test_arm_home_no_pending_when_code_not_req(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_no_pending_when_code_not_req(hass, service, expected_state): + """Test no pending when code not required.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -80,13 +115,28 @@ async def test_arm_home_no_pending_when_code_not_req(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, 0) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME + assert hass.states.get(entity_id).state == expected_state -async def test_arm_home_with_pending(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_pending(hass, service, expected_state): + """Test with pending after arming.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -106,12 +156,17 @@ async def test_arm_home_with_pending(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, CODE, entity_id) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_ARMING state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_HOME + assert state.attributes["next_state"] == expected_state future = dt_util.utcnow() + timedelta(seconds=1) with patch( @@ -122,11 +177,31 @@ async def test_arm_home_with_pending(hass): await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_HOME + assert state.state == expected_state + + # Do not go to the pending state when updating to the same state + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE}, + blocking=True, + ) + + assert hass.states.get(entity_id).state == expected_state -async def test_arm_home_with_invalid_code(hass): - """Attempt to arm home without a valid code.""" +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_invalid_code(hass, service, expected_state): + """Attempt to arm without a valid code.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -146,65 +221,27 @@ async def test_arm_home_with_invalid_code(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - -async def test_arm_away_no_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, + await hass.services.async_call( alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, + blocking=True, ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_away(hass, CODE, entity_id) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY - - -async def test_arm_away_no_pending_when_code_not_req(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "code_arm_required": False, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_away(hass, 0, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY - - -async def test_arm_home_with_template_code(hass): +@pytest.mark.parametrize( + "service,expected_state", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_template_code(hass, service, expected_state): """Attempt to arm with a template-based code.""" assert await async_setup_component( hass, @@ -225,14 +262,29 @@ async def test_arm_home_with_template_code(hass): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await common.async_alarm_arm_home(hass, "abc") + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "abc"}, + blocking=True, + ) state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_HOME + assert state.state == expected_state -async def test_arm_away_with_pending(hass): - """Test arm home method.""" +@pytest.mark.parametrize( + "service,expected_state,", + [ + (SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, STATE_ALARM_ARMED_NIGHT), + (SERVICE_ALARM_ARM_VACATION, STATE_ALARM_ARMED_VACATION), + ], +) +async def test_with_specific_pending(hass, service, expected_state): + """Test arming with specific pending.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -240,9 +292,8 @@ async def test_arm_away_with_pending(hass): "alarm_control_panel": { "platform": "manual", "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, + "arming_time": 10, + expected_state: {"arming_time": 2}, } }, ) @@ -250,16 +301,16 @@ async def test_arm_away_with_pending(hass): entity_id = "alarm_control_panel.test" - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_away(hass, CODE) + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_AWAY - - future = dt_util.utcnow() + timedelta(seconds=1) + future = dt_util.utcnow() + timedelta(seconds=2) with patch( ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), return_value=future, @@ -267,158 +318,7 @@ async def test_arm_away_with_pending(hass): async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_AWAY - - -async def test_arm_away_with_invalid_code(hass): - """Attempt to arm away without a valid code.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_away(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - -async def test_arm_night_no_pending(hass): - """Test arm night method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, CODE) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - -async def test_arm_night_no_pending_when_code_not_req(hass): - """Test arm night method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "code_arm_required": False, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, 0) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - -async def test_arm_night_with_pending(hass): - """Test arm night method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, CODE, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_NIGHT - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_NIGHT - - # Do not go to the pending state when updating to the same state - await common.async_alarm_arm_night(hass, CODE, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - -async def test_arm_night_with_invalid_code(hass): - """Attempt to night home without a valid code.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_night(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED + assert hass.states.get(entity_id).state == expected_state async def test_trigger_no_pending(hass): @@ -806,105 +706,6 @@ async def test_trigger_with_pending_and_specific_delay(hass): assert state.state == STATE_ALARM_TRIGGERED -async def test_armed_home_with_specific_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_home": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_home(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME - - -async def test_armed_away_with_specific_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_away": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_away(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY - - -async def test_armed_night_with_specific_pending(hass): - """Test arm home method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_night": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_night(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT - - async def test_trigger_with_specific_pending(hass): """Test arm home method.""" assert await async_setup_component( @@ -1298,158 +1099,6 @@ async def test_disarm_with_template_code(hass): assert state.state == STATE_ALARM_DISARMED -async def test_arm_custom_bypass_no_pending(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, CODE) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - -async def test_arm_custom_bypass_no_pending_when_code_not_req(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "code_arm_required": False, - "arming_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, 0) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - -async def test_arm_custom_bypass_with_pending(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, CODE, entity_id) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - state = hass.states.get(entity_id) - assert state.attributes["next_state"] == STATE_ALARM_ARMED_CUSTOM_BYPASS - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - -async def test_arm_custom_bypass_with_invalid_code(hass): - """Attempt to custom bypass without a valid code.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "code": CODE, - "arming_time": 1, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - await common.async_alarm_arm_custom_bypass(hass, CODE + "2") - - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - - -async def test_armed_custom_bypass_with_specific_pending(hass): - """Test arm custom bypass method.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 10, - "armed_custom_bypass": {"arming_time": 2}, - } - }, - ) - await hass.async_block_till_done() - - entity_id = "alarm_control_panel.test" - - await common.async_alarm_arm_custom_bypass(hass) - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMING - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch( - ("homeassistant.components.manual.alarm_control_panel.dt_util.utcnow"), - return_value=future, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - async def test_arm_away_after_disabled_disarmed(hass, legacy_patchable_time): """Test pending state with and without zero trigger time.""" assert await async_setup_component( @@ -1518,11 +1167,20 @@ async def test_arm_away_after_disabled_disarmed(hass, legacy_patchable_time): assert state.state == STATE_ALARM_TRIGGERED -async def test_restore_armed_state(hass): - """Ensure armed state is restored on startup.""" - mock_restore_cache( - hass, (State("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY),) - ) +@pytest.mark.parametrize( + "expected_state", + [ + (STATE_ALARM_ARMED_AWAY), + (STATE_ALARM_ARMED_CUSTOM_BYPASS), + (STATE_ALARM_ARMED_HOME), + (STATE_ALARM_ARMED_NIGHT), + (STATE_ALARM_ARMED_VACATION), + (STATE_ALARM_DISARMED), + ], +) +async def test_restore_state(hass, expected_state): + """Ensure state is restored on startup.""" + mock_restore_cache(hass, (State("alarm_control_panel.test", expected_state),)) hass.state = CoreState.starting mock_component(hass, "recorder") @@ -1544,31 +1202,4 @@ async def test_restore_armed_state(hass): state = hass.states.get("alarm_control_panel.test") assert state - assert state.state == STATE_ALARM_ARMED_AWAY - - -async def test_restore_disarmed_state(hass): - """Ensure disarmed state is restored on startup.""" - mock_restore_cache(hass, (State("alarm_control_panel.test", STATE_ALARM_DISARMED),)) - - hass.state = CoreState.starting - mock_component(hass, "recorder") - - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - "alarm_control_panel": { - "platform": "manual", - "name": "test", - "arming_time": 0, - "trigger_time": 0, - "disarm_after_trigger": False, - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test") - assert state - assert state.state == STATE_ALARM_DISARMED + assert state.state == expected_state diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 1e3b999d5f5..c0e1b4c2a85 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -1,5 +1,6 @@ """Notify platform tests for mobile_app.""" from datetime import datetime, timedelta +from unittest.mock import patch import pytest @@ -204,3 +205,130 @@ async def test_notify_ws_works( "code": "unauthorized", "message": "User not linked to this webhook ID", } + + +async def test_notify_ws_confirming_works( + hass, aioclient_mock, setup_push_receiver, hass_ws_client +): + """Test notify confirming works.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "mobile_app/push_notification_channel", + "webhook_id": "mock-webhook_id", + "support_confirm": True, + } + ) + + sub_result = await client.receive_json() + assert sub_result["success"] + + # Sent a message that will be delivered locally + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world"}, blocking=True + ) + + msg_result = await client.receive_json() + confirm_id = msg_result["event"].pop("hass_confirm_id") + assert confirm_id is not None + assert msg_result["event"] == {"message": "Hello world"} + + # Try to confirm with incorrect confirm ID + await client.send_json( + { + "id": 6, + "type": "mobile_app/push_notification_confirm", + "webhook_id": "mock-webhook_id", + "confirm_id": "incorrect-confirm-id", + } + ) + + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "not_found", + "message": "Push notification channel not found", + } + + # Confirm with correct confirm ID + await client.send_json( + { + "id": 7, + "type": "mobile_app/push_notification_confirm", + "webhook_id": "mock-webhook_id", + "confirm_id": confirm_id, + } + ) + + result = await client.receive_json() + assert result["success"] + + # Drop local push channel and try to confirm another message + await client.send_json( + { + "id": 8, + "type": "unsubscribe_events", + "subscription": 5, + } + ) + sub_result = await client.receive_json() + assert sub_result["success"] + + await client.send_json( + { + "id": 9, + "type": "mobile_app/push_notification_confirm", + "webhook_id": "mock-webhook_id", + "confirm_id": confirm_id, + } + ) + + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "not_found", + "message": "Push notification channel not found", + } + + +async def test_notify_ws_not_confirming( + hass, aioclient_mock, setup_push_receiver, hass_ws_client +): + """Test we go via cloud when failed to confirm.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "mobile_app/push_notification_channel", + "webhook_id": "mock-webhook_id", + "support_confirm": True, + } + ) + + sub_result = await client.receive_json() + assert sub_result["success"] + + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world 1"}, blocking=True + ) + + with patch( + "homeassistant.components.mobile_app.push_notification.PUSH_CONFIRM_TIMEOUT", 0 + ): + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world 2"}, blocking=True + ) + await hass.async_block_till_done() + + # When we fail, all unconfirmed ones and failed one are sent via cloud + assert len(aioclient_mock.mock_calls) == 2 + + # All future ones also go via cloud + assert await hass.services.async_call( + "notify", "mobile_app_test", {"message": "Hello world 3"}, blocking=True + ) + + assert len(aioclient_mock.mock_calls) == 3 diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 7942a8193b3..bd2ed9f7778 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -39,12 +39,17 @@ def mock_pymodbus(): """Mock pymodbus.""" mock_pb = mock.MagicMock() with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb + "homeassistant.components.modbus.modbus.ModbusTcpClient", + return_value=mock_pb, + autospec=True, ), mock.patch( "homeassistant.components.modbus.modbus.ModbusSerialClient", return_value=mock_pb, + autospec=True, ), mock.patch( - "homeassistant.components.modbus.modbus.ModbusUdpClient", return_value=mock_pb + "homeassistant.components.modbus.modbus.ModbusUdpClient", + return_value=mock_pb, + autospec=True, ): yield mock_pb @@ -96,10 +101,16 @@ async def mock_modbus( } mock_pb = mock.MagicMock() with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb + "homeassistant.components.modbus.modbus.ModbusTcpClient", + return_value=mock_pb, + autospec=True, ): now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + with mock.patch( + "homeassistant.helpers.event.dt_util.utcnow", + return_value=now, + autospec=True, + ): result = await async_setup_component(hass, DOMAIN, config) assert result or not check_config_loaded await hass.async_block_till_done() @@ -131,7 +142,9 @@ async def mock_pymodbus_return(hass, register_words, mock_modbus): async def mock_do_cycle(hass, mock_pymodbus_exception, mock_pymodbus_return): """Trigger update call with time_changed event.""" now = dt_util.utcnow() + timedelta(seconds=90) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + with mock.patch( + "homeassistant.helpers.event.dt_util.utcnow", return_value=now, autospec=True + ): async_fire_time_changed(hass, now) await hass.async_block_till_done() diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 1bb538a886a..ba99df19b4d 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -53,6 +53,8 @@ from homeassistant.components.modbus.const import ( MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, SERIAL, + SERVICE_RESTART, + SERVICE_STOP, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, TCP, @@ -79,8 +81,10 @@ from homeassistant.const import ( CONF_STRUCTURE, CONF_TIMEOUT, CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -601,7 +605,7 @@ async def test_pymodbus_constructor_fail(hass, caplog): ] } with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient" + "homeassistant.components.modbus.modbus.ModbusTcpClient", autospec=True ) as mock_pb: caplog.set_level(logging.ERROR) mock_pb.side_effect = ModbusException("test no class") @@ -668,7 +672,9 @@ async def test_delay(hass, mock_pymodbus): # pass first scan_interval start_time = now now = now + timedelta(seconds=(test_scan_interval + 1)) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + with mock.patch( + "homeassistant.helpers.event.dt_util.utcnow", return_value=now, autospec=True + ): async_fire_time_changed(hass, now) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE @@ -686,3 +692,80 @@ async def test_delay(hass, mock_pymodbus): async_fire_time_changed(hass, now) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SCAN_INTERVAL: 0, + } + ], + }, + ], +) +async def test_shutdown(hass, caplog, mock_pymodbus, mock_modbus_with_pymodbus): + """Run test for shutdown.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert mock_pymodbus.close.called + assert caplog.text == "" + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + } + ] + }, + ], +) +async def test_stop_restart(hass, caplog, mock_modbus): + """Run test for service stop.""" + + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + hass.states.async_set(entity_id, 17) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == "17" + + mock_modbus.reset_mock() + caplog.clear() + data = { + ATTR_HUB: TEST_MODBUS_NAME, + } + await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert mock_modbus.close.called + assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text + + mock_modbus.reset_mock() + caplog.clear() + await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) + await hass.async_block_till_done() + assert not mock_modbus.close.called + assert mock_modbus.connect.called + assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text + + mock_modbus.reset_mock() + caplog.clear() + await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) + await hass.async_block_till_done() + assert mock_modbus.close.called + assert mock_modbus.connect.called + assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text + assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 0da9d86f262..e2435d6acc8 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_LAZY_ERROR, CONF_PRECISION, CONF_SCALE, - CONF_STATE_CLASS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, @@ -22,6 +21,7 @@ from homeassistant.components.modbus.const import ( DATA_TYPE_UINT, ) from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, ) @@ -246,6 +246,7 @@ async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, capl { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 1, }, ], }, diff --git a/tests/components/modem_callerid/__init__.py b/tests/components/modem_callerid/__init__.py new file mode 100644 index 00000000000..2ff0e87c9cd --- /dev/null +++ b/tests/components/modem_callerid/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the Modem Caller ID integration.""" + +from unittest.mock import patch + +from phone_modem import DEFAULT_PORT + +from homeassistant.const import CONF_DEVICE + +CONF_DATA = {CONF_DEVICE: DEFAULT_PORT} + +IMPORT_DATA = {"sensor": {"platform": "modem_callerid"}} + + +def _patch_init_modem(mocked_modem): + return patch( + "homeassistant.components.modem_callerid.PhoneModem", + return_value=mocked_modem, + ) + + +def _patch_config_flow_modem(mocked_modem): + return patch( + "homeassistant.components.modem_callerid.config_flow.PhoneModem", + return_value=mocked_modem, + ) diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py new file mode 100644 index 00000000000..5a2e4e5fd6d --- /dev/null +++ b/tests/components/modem_callerid/test_config_flow.py @@ -0,0 +1,204 @@ +"""Test Modem Caller ID config flow.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import phone_modem +import serial.tools.list_ports + +from homeassistant.components import usb +from homeassistant.components.modem_callerid.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USB, SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import CONF_DATA, IMPORT_DATA, _patch_config_flow_modem + +DISCOVERY_INFO = { + "device": phone_modem.DEFAULT_PORT, + "pid": "1340", + "vid": "0572", + "serial_number": "1234", + "description": "modem", + "manufacturer": "Connexant", +} + + +def _patch_setup(): + return patch( + "homeassistant.components.modem_callerid.async_setup_entry", + return_value=True, + ) + + +def com_port(): + """Mock of a serial port.""" + port = serial.tools.list_ports_common.ListPortInfo(phone_modem.DEFAULT_PORT) + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = phone_modem.DEFAULT_PORT + port.description = "Some serial port" + + return port + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb(hass: HomeAssistant): + """Test usb discovery flow.""" + port = com_port() + with _patch_config_flow_modem(AsyncMock()), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE: phone_modem.DEFAULT_PORT}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEVICE: port.device} + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb_cannot_connect(hass: HomeAssistant): + """Test usb flow connection error.""" + with _patch_config_flow_modem(AsyncMock()) as modemmock: + modemmock.side_effect = phone_modem.exceptions.SerialError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_user(hass: HomeAssistant): + """Test user initialized flow.""" + port = com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + mocked_modem = AsyncMock() + with _patch_config_flow_modem(mocked_modem), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_DEVICE: port_select}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEVICE: port.device} + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_DEVICE: port_select}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_user_error(hass: HomeAssistant): + """Test user initialized flow with unreachable device.""" + port = com_port() + port_select = usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + with _patch_config_flow_modem(AsyncMock()) as modemmock: + modemmock.side_effect = phone_modem.exceptions.SerialError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={CONF_DEVICE: port_select} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + modemmock.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE: port_select}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEVICE: port.device} + + +@patch("serial.tools.list_ports.comports", MagicMock()) +async def test_flow_user_no_port_list(hass: HomeAssistant): + """Test user with no list of ports.""" + with _patch_config_flow_modem(AsyncMock()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_DEVICE: phone_modem.DEFAULT_PORT}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + + +async def test_abort_user_with_existing_flow(hass: HomeAssistant): + """Test user flow is aborted when another discovery has happened.""" + with _patch_config_flow_modem(AsyncMock()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={}, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_import(hass: HomeAssistant): + """Test import step.""" + with _patch_config_flow_modem(AsyncMock()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import_cannot_connect(hass: HomeAssistant): + """Test import connection error.""" + with _patch_config_flow_modem(AsyncMock()) as modemmock: + modemmock.side_effect = phone_modem.exceptions.SerialError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/modem_callerid/test_init.py b/tests/components/modem_callerid/test_init.py new file mode 100644 index 00000000000..b288fb7dc9f --- /dev/null +++ b/tests/components/modem_callerid/test_init.py @@ -0,0 +1,63 @@ +"""Test Modem Caller ID integration.""" +from unittest.mock import AsyncMock, patch + +from phone_modem import exceptions + +from homeassistant.components.modem_callerid.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import CONF_DATA, _patch_init_modem + +from tests.common import MockConfigEntry + + +async def test_setup_config(hass: HomeAssistant): + """Test Modem Caller ID setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + entry.add_to_hass(hass) + mocked_modem = AsyncMock() + with _patch_init_modem(mocked_modem): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + + +async def test_async_setup_entry_not_ready(hass: HomeAssistant): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.modem_callerid.PhoneModem", + side_effect=exceptions.SerialError(), + ): + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_unload_config_entry(hass: HomeAssistant): + """Test unload.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + entry.add_to_hass(hass) + mocked_modem = AsyncMock() + with _patch_init_modem(mocked_modem): + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 61c7f73b5fb..b0db6169373 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -4,9 +4,9 @@ import json import pytest import homeassistant.components.automation as automation -from homeassistant.components.mqtt import DOMAIN, debug_info -from homeassistant.components.mqtt.device_trigger import async_attach_trigger +from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component from tests.common import ( @@ -697,18 +697,22 @@ async def test_attach_remove(hass, device_reg, mqtt_mock): def callback(trigger): calls.append(trigger["trigger"]["payload"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "bla1", - "type": "button_short_press", - "subtype": "button_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. @@ -751,18 +755,22 @@ async def test_attach_remove_late(hass, device_reg, mqtt_mock): def callback(trigger): calls.append(trigger["trigger"]["payload"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "bla1", - "type": "button_short_press", - "subtype": "button_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) @@ -808,18 +816,22 @@ async def test_attach_remove_late2(hass, device_reg, mqtt_mock): def callback(trigger): calls.append(trigger["trigger"]["payload"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "bla1", - "type": "button_short_press", - "subtype": "button_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the trigger diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ab0c58fb3b6..dfdd316cda9 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -12,13 +12,13 @@ from homeassistant.components import mqtt, websocket_api from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.const import ( - ATTR_DOMAIN, - ATTR_SERVICE, EVENT_CALL_SERVICE, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import CoreState, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -97,21 +97,35 @@ async def test_publish_calls_service(hass, mqtt_mock, calls, record_calls): hass.bus.async_listen_once(EVENT_CALL_SERVICE, record_calls) mqtt.async_publish(hass, "test-topic", "test-payload") - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0][0].data["service_data"][mqtt.ATTR_TOPIC] == "test-topic" assert calls[0][0].data["service_data"][mqtt.ATTR_PAYLOAD] == "test-payload" + assert mqtt.ATTR_QOS not in calls[0][0].data["service_data"] + assert mqtt.ATTR_RETAIN not in calls[0][0].data["service_data"] + + hass.bus.async_listen_once(EVENT_CALL_SERVICE, record_calls) + + mqtt.async_publish(hass, "test-topic", "test-payload", 2, True) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert calls[1][0].data["service_data"][mqtt.ATTR_TOPIC] == "test-topic" + assert calls[1][0].data["service_data"][mqtt.ATTR_PAYLOAD] == "test-payload" + assert calls[1][0].data["service_data"][mqtt.ATTR_QOS] == 2 + assert calls[1][0].data["service_data"][mqtt.ATTR_RETAIN] is True async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): """Test the service call if topic is missing.""" - hass.bus.fire( - EVENT_CALL_SERVICE, - {ATTR_DOMAIN: mqtt.DOMAIN, ATTR_SERVICE: mqtt.SERVICE_PUBLISH}, - ) - await hass.async_block_till_done() + with pytest.raises(vol.Invalid): + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + {}, + blocking=True, + ) assert not mqtt_mock.async_publish.called @@ -120,10 +134,37 @@ async def test_service_call_with_template_payload_renders_template(hass, mqtt_mo If 'payload_template' is provided and 'payload' is not, then render it. """ - mqtt.async_publish_template(hass, "test/topic", "{{ 1+1 }}") + mqtt.publish_template(hass, "test/topic", "{{ 1+1 }}") await hass.async_block_till_done() assert mqtt_mock.async_publish.called assert mqtt_mock.async_publish.call_args[0][1] == "2" + mqtt_mock.reset_mock() + + mqtt.async_publish_template(hass, "test/topic", "{{ 2+2 }}") + await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "4" + mqtt_mock.reset_mock() + + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + {mqtt.ATTR_TOPIC: "test/topic", mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 4+4 }}"}, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "8" + + +async def test_service_call_with_bad_template(hass, mqtt_mock): + """Test the service call with a bad template does not publish.""" + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + {mqtt.ATTR_TOPIC: "test/topic", mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 1 | bad }}"}, + blocking=True, + ) + assert not mqtt_mock.async_publish.called async def test_service_call_with_payload_doesnt_render_template(hass, mqtt_mock): @@ -340,6 +381,34 @@ async def test_subscribe_topic(hass, mqtt_mock, calls, record_calls): assert len(calls) == 1 +async def test_subscribe_topic_non_async(hass, mqtt_mock, calls, record_calls): + """Test the subscription of a topic using the non-async function.""" + unsub = await hass.async_add_executor_job( + mqtt.subscribe, hass, "test-topic", record_calls + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test-topic", "test-payload") + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0][0].topic == "test-topic" + assert calls[0][0].payload == "test-payload" + + await hass.async_add_executor_job(unsub) + + async_fire_mqtt_message(hass, "test-topic", "test-payload") + + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_subscribe_bad_topic(hass, mqtt_mock, calls, record_calls): + """Test the subscription of a topic.""" + with pytest.raises(HomeAssistantError): + await mqtt.async_subscribe(hass, 55, record_calls) + + async def test_subscribe_deprecated(hass, mqtt_mock): """Test the subscription of a topic using deprecated callback signature.""" calls = [] @@ -833,6 +902,62 @@ async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): mqtt_client_mock.publish.assert_not_called() +@pytest.mark.parametrize( + "mqtt_config", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "homeassistant/status", + mqtt.ATTR_PAYLOAD: "online", + }, + } + ], +) +async def test_delayed_birth_message(hass, mqtt_client_mock, mqtt_config): + """Test sending birth message does not happen until Home Assistant starts.""" + hass.state = CoreState.starting + birth = asyncio.Event() + + result = await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: mqtt_config}) + assert result + await hass.async_block_till_done() + + # Workaround: asynctest==0.13 fails on @functools.lru_cache + spec = dir(hass.data["mqtt"]) + spec.remove("_matching_subscriptions") + + mqtt_component_mock = MagicMock( + return_value=hass.data["mqtt"], + spec_set=spec, + wraps=hass.data["mqtt"], + ) + mqtt_component_mock._mqttc = mqtt_client_mock + + hass.data["mqtt"] = mqtt_component_mock + mqtt_mock = hass.data["mqtt"] + mqtt_mock.reset_mock() + + async def wait_birth(topic, payload, qos): + """Handle birth message.""" + birth.set() + + with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1): + await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) + mqtt_mock._mqtt_on_connect(None, None, 0, 0) + await hass.async_block_till_done() + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(birth.wait(), 0.2) + assert not mqtt_client_mock.publish.called + assert not birth.is_set() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await birth.wait() + mqtt_client_mock.publish.assert_called_with( + "homeassistant/status", "online", 0, False + ) + + @pytest.mark.parametrize( "mqtt_config", [ diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 93db43e40c9..8f62830b219 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,10 +1,9 @@ """Test the Nanoleaf config flow.""" from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pynanoleaf import InvalidToken, NotAuthorizingNewTokens, Unavailable -from pynanoleaf.pynanoleaf import NanoleafError +from aionanoleaf import InvalidToken, NanoleafException, Unauthorized, Unavailable import pytest from homeassistant import config_entries @@ -23,6 +22,21 @@ TEST_DEVICE_ID = "5E:2E:EA:XX:XX:XX" TEST_OTHER_DEVICE_ID = "5E:2E:EA:YY:YY:YY" +def _mock_nanoleaf( + host: str = TEST_HOST, + auth_token: str = TEST_TOKEN, + authorize_error: Exception | None = None, + get_info_error: Exception | None = None, +): + nanoleaf = MagicMock() + nanoleaf.name = TEST_NAME + nanoleaf.host = host + nanoleaf.auth_token = auth_token + nanoleaf.authorize = AsyncMock(side_effect=authorize_error) + nanoleaf.get_info = AsyncMock(side_effect=get_info_error) + return nanoleaf + + async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None: """Test we handle Unavailable in user and link step.""" result = await hass.config_entries.flow.async_init( @@ -30,7 +44,7 @@ async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None ) with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Unavailable("message"), + side_effect=Unavailable, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -58,7 +72,7 @@ async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Unavailable("message"), + side_effect=Unavailable, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -71,8 +85,8 @@ async def test_user_unavailable_user_step_link_step(hass: HomeAssistant) -> None @pytest.mark.parametrize( "error, reason", [ - (Unavailable("message"), "cannot_connect"), - (InvalidToken("message"), "invalid_token"), + (Unavailable, "cannot_connect"), + (InvalidToken, "invalid_token"), (Exception, "unknown"), ], ) @@ -85,7 +99,6 @@ async def test_user_error_setup_finish( ) with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -98,9 +111,8 @@ async def test_user_error_setup_finish( with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", side_effect=error, ): result3 = await hass.config_entries.flow.async_configure( @@ -117,19 +129,10 @@ async def test_user_not_authorizing_new_tokens_user_step_link_step( """Test we handle NotAuthorizingNewTokens in user step and link step.""" with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(authorize_error=Unauthorized()), ) as mock_nanoleaf, patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, - ), patch( "homeassistant.components.nanoleaf.async_setup_entry", return_value=True ) as mock_setup_entry: - nanoleaf = mock_nanoleaf.return_value - nanoleaf.authorize.side_effect = NotAuthorizingNewTokens( - "Not authorizing new tokens" - ) - nanoleaf.host = TEST_HOST - nanoleaf.token = TEST_TOKEN - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -160,8 +163,7 @@ async def test_user_not_authorizing_new_tokens_user_step_link_step( assert result4["errors"] == {"base": "not_allowing_new_tokens"} assert result4["step_id"] == "link" - nanoleaf.authorize.side_effect = None - nanoleaf.authorize.return_value = None + mock_nanoleaf.return_value.authorize.side_effect = None result5 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -183,8 +185,8 @@ async def test_user_exception_user_step(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Exception, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(authorize_error=Exception()), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -198,36 +200,29 @@ async def test_user_exception_user_step(hass: HomeAssistant) -> None: assert not result2["last_step"] with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, - ): + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(), + ) as mock_nanoleaf: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: TEST_HOST, }, ) - assert result3["step_id"] == "link" + assert result3["step_id"] == "link" + + mock_nanoleaf.return_value.authorize.side_effect = Exception() - with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Exception, - ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, ) - assert result4["type"] == "form" - assert result4["step_id"] == "link" - assert result4["errors"] == {"base": "unknown"} + assert result4["type"] == "form" + assert result4["step_id"] == "link" + assert result4["errors"] == {"base": "unknown"} - with patch( - "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - return_value=None, - ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - side_effect=Exception, - ): + mock_nanoleaf.return_value.authorize.side_effect = None + mock_nanoleaf.return_value.get_info.side_effect = Exception() result5 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, @@ -249,8 +244,7 @@ async def test_discovery_link_unavailable( ) -> None: """Test discovery and abort if device is unavailable.""" with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", ), patch( "homeassistant.components.nanoleaf.config_flow.load_json", return_value={}, @@ -278,7 +272,7 @@ async def test_discovery_link_unavailable( with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", - side_effect=Unavailable("message"), + side_effect=Unavailable, ): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "abort" @@ -287,10 +281,6 @@ async def test_discovery_link_unavailable( async def test_reauth(hass: HomeAssistant) -> None: """Test Nanoleaf reauth flow.""" - nanoleaf = MagicMock() - nanoleaf.host = TEST_HOST - nanoleaf.token = TEST_TOKEN - entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_NAME, @@ -300,7 +290,7 @@ async def test_reauth(hass: HomeAssistant) -> None: with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf", - return_value=nanoleaf, + return_value=_mock_nanoleaf(), ), patch( "homeassistant.components.nanoleaf.async_setup_entry", return_value=True, @@ -331,8 +321,8 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_import_config(hass: HomeAssistant) -> None: """Test configuration import.""" with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(TEST_HOST, TEST_TOKEN), ), patch( "homeassistant.components.nanoleaf.async_setup_entry", return_value=True, @@ -355,17 +345,17 @@ async def test_import_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "error, reason", [ - (Unavailable("message"), "cannot_connect"), - (InvalidToken("message"), "invalid_token"), + (Unavailable, "cannot_connect"), + (InvalidToken, "invalid_token"), (Exception, "unknown"), ], ) async def test_import_config_error( - hass: HomeAssistant, error: NanoleafError, reason: str + hass: HomeAssistant, error: NanoleafException, reason: str ) -> None: """Test configuration import with errors in setup_finish.""" with patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", side_effect=error, ): result = await hass.config_entries.flow.async_init( @@ -432,8 +422,8 @@ async def test_import_discovery_integration( "homeassistant.components.nanoleaf.config_flow.load_json", return_value=dict(nanoleaf_conf_file), ), patch( - "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", - return_value={"name": TEST_NAME}, + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(TEST_HOST, TEST_TOKEN), ), patch( "homeassistant.components.nanoleaf.config_flow.save_json", return_value=None, diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py index 3f07e4c4b0a..48bdf247f51 100644 --- a/tests/components/neato/test_config_flow.py +++ b/tests/components/neato/test_config_flow.py @@ -19,7 +19,7 @@ OAUTH2_TOKEN = VENDOR.token_endpoint async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -50,7 +50,7 @@ async def test_full_flow( "&scope=public_profile+control_robots+maps" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -91,7 +91,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant): async def test_reauth( - hass: HomeAssistant, aiohttp_client, aioclient_mock, current_request_with_host + hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test initialization of the reauth flow.""" assert await setup.async_setup_component( @@ -127,7 +127,7 @@ async def test_reauth( }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index f8c9c69698a..a8f892045f5 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -37,10 +37,10 @@ def get_config_entry(hass): class OAuthFixture: """Simulate the oauth flow used by the config flow.""" - def __init__(self, hass, aiohttp_client, aioclient_mock): + def __init__(self, hass, hass_client_no_auth, aioclient_mock): """Initialize OAuthFixture.""" self.hass = hass - self.aiohttp_client = aiohttp_client + self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock async def async_oauth_flow(self, result): @@ -63,7 +63,7 @@ class OAuthFixture: "&access_type=offline&prompt=consent" ) - client = await self.aiohttp_client(self.hass.http.app) + client = await self.hass_client() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -86,9 +86,9 @@ class OAuthFixture: @pytest.fixture -async def oauth(hass, aiohttp_client, aioclient_mock, current_request_with_host): +async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_host): """Create the simulated oauth flow.""" - return OAuthFixture(hass, aiohttp_client, aioclient_mock) + return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) async def test_full_flow(hass, oauth): diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 5ba989e2504..f2c03ac7de1 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -71,6 +71,17 @@ async def fake_post_request(*args, **kwargs): ) +async def fake_get_image(*args, **kwargs): + """Return fake data.""" + if "url" not in kwargs: + return "{}" + + endpoint = kwargs["url"].split("/")[-1] + + if endpoint in "snapshot_720.jpg": + return b"test stream image bytes" + + async def fake_post_request_no_data(*args, **kwargs): """Fake error during requesting backend data.""" return "{}" diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index d443802a41d..4d6bbb752f3 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest -from .common import ALL_SCOPES, fake_post_request +from .common import ALL_SCOPES, fake_get_image, fake_post_request from tests.common import MockConfigEntry @@ -60,6 +60,7 @@ def netatmo_auth(): "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth: mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_get_image.side_effect = fake_get_image mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 4825946beab..45c8dc48b22 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -14,6 +14,7 @@ from homeassistant.components.netatmo.const import ( SERVICE_SET_PERSONS_HOME, ) from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt from .common import fake_post_request, selected_platforms, simulate_webhook @@ -220,6 +221,60 @@ async def test_service_set_person_away(hass, config_entry, netatmo_auth): ) +async def test_service_set_person_away_invalid_person(hass, config_entry, netatmo_auth): + """Test service to set invalid person as away.""" + with selected_platforms(["camera"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + await hass.async_block_till_done() + + data = { + "entity_id": "camera.netatmo_hall", + "person": "Batman", + } + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + "netatmo", + SERVICE_SET_PERSON_AWAY, + service_data=data, + blocking=True, + ) + await hass.async_block_till_done() + + assert excinfo.value.args == ("Person(s) not registered ['Batman']",) + + +async def test_service_set_persons_home_invalid_person( + hass, config_entry, netatmo_auth +): + """Test service to set invalid persons as home.""" + with selected_platforms(["camera"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + await hass.async_block_till_done() + + data = { + "entity_id": "camera.netatmo_hall", + "persons": "Batman", + } + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + "netatmo", + SERVICE_SET_PERSONS_HOME, + service_data=data, + blocking=True, + ) + await hass.async_block_till_done() + + assert excinfo.value.args == ("Person(s) not registered ['Batman']",) + + async def test_service_set_persons_home(hass, config_entry, netatmo_auth): """Test service to set persons as home.""" with selected_platforms(["camera"]): @@ -423,6 +478,7 @@ async def test_camera_image_raises_exception(hass, config_entry, requests_mock): "homeassistant.components.webhook.async_generate_url" ): mock_auth.return_value.async_post_request.side_effect = fake_post + mock_auth.return_value.async_get_image.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 6bd8086c820..8f18ae1410a 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -44,7 +44,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -89,7 +89,7 @@ async def test_full_flow( f"&state={state}&scope={scope}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/netgear/__init__.py b/tests/components/netgear/__init__.py new file mode 100644 index 00000000000..7ef2f96cced --- /dev/null +++ b/tests/components/netgear/__init__.py @@ -0,0 +1 @@ +"""Tests for the Netgear component.""" diff --git a/tests/components/netgear/conftest.py b/tests/components/netgear/conftest.py new file mode 100644 index 00000000000..f60b9be62a5 --- /dev/null +++ b/tests/components/netgear/conftest.py @@ -0,0 +1,14 @@ +"""Configure Netgear tests.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(name="bypass_setup", autouse=True) +def bypass_setup_fixture(): + """Mock component setup.""" + with patch( + "homeassistant.components.netgear.device_tracker.async_get_scanner", + return_value=None, + ): + yield diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py new file mode 100644 index 00000000000..de4f4fba510 --- /dev/null +++ b/tests/components/netgear/test_config_flow.py @@ -0,0 +1,284 @@ +"""Tests for the Netgear config flow.""" +import logging +from unittest.mock import Mock, patch + +from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.netgear.const import CONF_CONSIDER_HOME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +URL = "http://routerlogin.net" +SERIAL = "5ER1AL0000001" + +ROUTER_INFOS = { + "Description": "Netgear Smart Wizard 3.0, specification 1.6 version", + "SignalStrength": "-4", + "SmartAgentversion": "3.0", + "FirewallVersion": "net-wall 2.0", + "VPNVersion": None, + "OthersoftwareVersion": "N/A", + "Hardwareversion": "N/A", + "Otherhardwareversion": "N/A", + "FirstUseDate": "Sunday, 30 Sep 2007 01:10:03", + "DeviceMode": "0", + "ModelName": "RBR20", + "SerialNumber": SERIAL, + "Firmwareversion": "V2.3.5.26", + "DeviceName": "Desk", + "DeviceNameUserSet": "true", + "FirmwareDLmethod": "HTTPS", + "FirmwareLastUpdate": "2019_10.5_18:42:58", + "FirmwareLastChecked": "2020_5.3_1:33:0", + "DeviceModeCapability": "0;1", +} +TITLE = f"{ROUTER_INFOS['ModelName']} - {ROUTER_INFOS['DeviceName']}" + +HOST = "10.0.0.1" +SERIAL_2 = "5ER1AL0000002" +PORT = 80 +SSL = False +USERNAME = "Home_Assistant" +PASSWORD = "password" +SSDP_URL = f"http://{HOST}:{PORT}/rootDesc.xml" +SSDP_URL_SLL = f"https://{HOST}:{PORT}/rootDesc.xml" + + +@pytest.fixture(name="service") +def mock_controller_service(): + """Mock a successful service.""" + with patch( + "homeassistant.components.netgear.async_setup_entry", return_value=True + ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: + service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) + yield service_mock + + +@pytest.fixture(name="service_failed") +def mock_controller_service_failed(): + """Mock a failed service.""" + with patch("homeassistant.components.netgear.router.Netgear") as service_mock: + service_mock.return_value.login = Mock(return_value=None) + service_mock.return_value.get_info = Mock(return_value=None) + yield service_mock + + +async def test_user(hass, service): + """Test user step.""" + 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" + + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + 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"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT + assert result["data"].get(CONF_SSL) == SSL + assert result["data"].get(CONF_USERNAME) == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_import_required(hass, service): + """Test import step, with required config only.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == DEFAULT_HOST + assert result["data"].get(CONF_PORT) == DEFAULT_PORT + assert result["data"].get(CONF_SSL) is False + assert result["data"].get(CONF_USERNAME) == DEFAULT_USER + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_import_required_login_failed(hass, service_failed): + """Test import step, with required config only, while wrong password or connection issue.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "config"} + + +async def test_import_all(hass, service): + """Test import step, with all config provided.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + 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"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT + assert result["data"].get(CONF_SSL) == SSL + assert result["data"].get(CONF_USERNAME) == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_import_all_connection_failed(hass, service_failed): + """Test import step, with all config provided, while wrong host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "config"} + + +async def test_abort_if_already_setup(hass, service): + """Test we abort if the router is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + ).add_to_hass(hass) + + # Should fail, same SERIAL (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Should fail, same SERIAL (flow) + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_already_configured(hass): + """Test ssdp abort when the router is already configured.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: SSDP_URL_SLL, + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp(hass, service): + """Test ssdp step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: SSDP_URL, + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT + assert result["data"].get(CONF_SSL) == SSL + assert result["data"].get(CONF_USERNAME) == DEFAULT_USER + assert result["data"][CONF_PASSWORD] == PASSWORD + + +async def test_options_flow(hass, service): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PASSWORD: PASSWORD}, + unique_id=SERIAL, + title=TITLE, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == 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_CONSIDER_HOME: 1800, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 1800, + } diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index 6a85f5ea9e8..12d317e826a 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,5 +1,6 @@ """Test the Network Configuration.""" -from unittest.mock import Mock, patch +from ipaddress import IPv4Address +from unittest.mock import MagicMock, Mock, patch import ifaddr @@ -17,6 +18,18 @@ _NO_LOOPBACK_IPADDR = "192.168.1.5" _LOOPBACK_IPADDR = "127.0.0.1" +def _mock_socket(sockname): + mock_socket = MagicMock() + mock_socket.getsockname = Mock(return_value=sockname) + return mock_socket + + +def _mock_socket_exception(exc): + mock_socket = MagicMock() + mock_socket.getsockname = Mock(side_effect=exc) + return mock_socket + + def _generate_mock_adapters(): mock_lo0 = Mock(spec=ifaddr.Adapter) mock_lo0.nice_name = "lo0" @@ -40,8 +53,8 @@ def _generate_mock_adapters(): async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_storage): """Test without default interface config and the route returns a non-loopback address.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_NO_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -102,8 +115,8 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage): """Test without default interface config and the route returns a loopback address.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -163,8 +176,8 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): """Test without default interface config and the route returns nothing.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -224,8 +237,8 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): async def test_async_detect_interfaces_setting_exception(hass, hass_storage): """Test without default interface config and the route throws an exception.""" with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - side_effect=AttributeError, + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket_exception(AttributeError), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -290,8 +303,8 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_NO_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -359,8 +372,8 @@ async def test_interfaces_configured_from_storage_websocket_update( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } with patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[_NO_LOOPBACK_IPADDR], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), ), patch( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), @@ -491,8 +504,8 @@ async def test_async_get_source_ip_matching_interface(hass, hass_storage): "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"], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), ): assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) await hass.async_block_till_done() @@ -512,8 +525,8 @@ async def test_async_get_source_ip_interface_not_match(hass, hass_storage): "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"], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), ): assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) await hass.async_block_till_done() @@ -533,10 +546,58 @@ async def test_async_get_source_ip_cannot_determine_target(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ), patch( - "homeassistant.components.network.util.socket.socket.getsockname", - return_value=[None], + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([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" + + +async def test_async_get_ipv4_broadcast_addresses_default(hass, hass_storage): + """Test getting ipv4 broadcast addresses when only the default address is enabled.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, + } + + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_ipv4_broadcast_addresses(hass) == { + IPv4Address("255.255.255.255") + } + + +async def test_async_get_ipv4_broadcast_addresses_multiple(hass, hass_storage): + """Test getting ipv4 broadcast addresses when multiple adapters are enabled.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1", "vtun0"]}, + } + + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([_LOOPBACK_IPADDR]), + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_ipv4_broadcast_addresses(hass) == { + IPv4Address("255.255.255.255"), + IPv4Address("192.168.1.255"), + IPv4Address("169.254.255.255"), + } diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 74997df5a4f..0c6e8f8f7e8 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -23,7 +23,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( "hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"] ) -async def test_form(hass: HomeAssistant, hosts: str) -> None: +async def test_form(hass: HomeAssistant, hosts: str, mock_get_source_ip) -> None: """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -62,7 +62,7 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_range(hass: HomeAssistant) -> None: +async def test_form_range(hass: HomeAssistant, mock_get_source_ip) -> None: """Test we get the form and can take an ip range.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -98,7 +98,7 @@ async def test_form_range(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_hosts(hass: HomeAssistant) -> None: +async def test_form_invalid_hosts(hass: HomeAssistant, mock_get_source_ip) -> None: """Test invalid hosts passed in.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -122,7 +122,7 @@ async def test_form_invalid_hosts(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} -async def test_form_already_configured(hass: HomeAssistant) -> None: +async def test_form_already_configured(hass: HomeAssistant, mock_get_source_ip) -> None: """Test duplicate host list.""" await setup.async_setup_component(hass, "persistent_notification", {}) config_entry = MockConfigEntry( @@ -157,7 +157,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_form_invalid_excludes(hass: HomeAssistant) -> None: +async def test_form_invalid_excludes(hass: HomeAssistant, mock_get_source_ip) -> None: """Test invalid excludes passed in.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -181,7 +181,7 @@ async def test_form_invalid_excludes(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant, mock_get_source_ip) -> None: """Test we can edit options.""" config_entry = MockConfigEntry( @@ -243,7 +243,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import(hass: HomeAssistant) -> None: +async def test_import(hass: HomeAssistant, mock_get_source_ip) -> None: """Test we can import from yaml.""" await setup.async_setup_component(hass, "persistent_notification", {}) with patch( @@ -278,7 +278,9 @@ async def test_import(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_aborts_if_matching(hass: HomeAssistant) -> None: +async def test_import_aborts_if_matching( + hass: HomeAssistant, mock_get_source_ip +) -> None: """Test we can import from yaml.""" config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index cce26750c1c..92b71091e07 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -1,6 +1,7 @@ """The tests for notify services that change targets.""" from homeassistant.components import notify from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_same_targets(hass: HomeAssistant): @@ -81,3 +82,19 @@ class NotificationService(notify.BaseNotificationService): def targets(self): """Return a dictionary of devices.""" return self.target_list + + +async def test_warn_template(hass, caplog): + """Test warning when template used.""" + assert await async_setup_component(hass, "notify", {}) + assert await async_setup_component(hass, "persistent_notification", {}) + + await hass.services.async_call( + "notify", + "persistent_notification", + {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"}, + blocking=True, + ) + # We should only log it once + assert caplog.text.count("Passing templates to notify service is deprecated") == 1 + assert hass.states.get("persistent_notification.notification") is not None diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index d9ed37d516c..bda70bd6af9 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -1,29 +1,31 @@ """Define tests for the Notion config flow.""" from unittest.mock import AsyncMock, patch -import aionotion +from aionotion.errors import InvalidCredentialsError, NotionError import pytest from homeassistant import data_entry_flow -from homeassistant.components.notion import DOMAIN, config_flow -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.notion import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry -@pytest.fixture -def mock_client(): - """Define a fixture for a client creation coroutine.""" +@pytest.fixture(name="client") +def client_fixture(): + """Define a fixture for an aionotion client.""" return AsyncMock(return_value=None) -@pytest.fixture -def mock_aionotion(mock_client): - """Mock the aionotion library.""" - with patch("homeassistant.components.notion.config_flow.async_get_client") as mock_: - mock_.side_effect = mock_client - yield mock_ +@pytest.fixture(name="client_login") +def client_login_fixture(client): + """Define a fixture for patching the aiowatttime coroutine to get a client.""" + with patch( + "homeassistant.components.notion.config_flow.async_get_client" + ) as mock_client: + mock_client.side_effect = client + yield mock_client async def test_duplicate_error(hass): @@ -37,47 +39,90 @@ async def test_duplicate_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -@pytest.mark.parametrize( - "mock_client", [AsyncMock(side_effect=aionotion.errors.NotionError)] -) -async def test_invalid_credentials(hass, mock_aionotion): - """Test that an invalid API/App Key throws an error.""" +@pytest.mark.parametrize("client", [AsyncMock(side_effect=NotionError)]) +async def test_generic_notion_error(client_login, hass): + """Test that a generic aionotion error is handled correctly.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["errors"] == {"base": "unknown"} + + +@pytest.mark.parametrize("client", [AsyncMock(side_effect=InvalidCredentialsError)]) +async def test_invalid_credentials(client_login, hass): + """Test that invalid credentials throw an error.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + await hass.async_block_till_done() - result = await flow.async_step_user(user_input=conf) assert result["errors"] == {"base": "invalid_auth"} -async def test_show_form(hass): - """Test that the form is served with no input.""" - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} +async def test_step_reauth(client_login, hass): + """Test that the reauth step works.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ).add_to_hass(hass) - result = await flow.async_step_user(user_input=None) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.notion.async_setup_entry", return_value=True + ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_show_form(client_login, hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_step_user(hass, mock_aionotion): +async def test_step_user(client_login, hass): """Test that the user step works.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + with patch("homeassistant.components.notion.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + await hass.async_block_till_done() - result = await flow.async_step_user(user_input=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "user@host.com" assert result["data"] == { diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 0d8fec71d51..a8c0945c6c0 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -21,7 +21,6 @@ async def test_pr3000rt2u(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -47,7 +46,6 @@ async def test_cp1350c(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -72,7 +70,6 @@ async def test_5e850i(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -97,7 +94,6 @@ async def test_5e650i(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online Battery Charging", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -125,7 +121,6 @@ async def test_backupsses600m1(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -152,7 +147,6 @@ async def test_cp1500pfclcd(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -177,7 +171,6 @@ async def test_dl650elcd(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -202,7 +195,6 @@ async def test_blazer_usb(hass): expected_attributes = { "device_class": "battery", "friendly_name": "Ups1 Battery Charge", - "state": "Online", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 75fcb9c0746..206cafa197b 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -91,14 +91,14 @@ async def mock_supervisor_fixture(hass, aioclient_mock): yield -async def test_onboarding_progress(hass, hass_storage, aiohttp_client): +async def test_onboarding_progress(hass, hass_storage, hass_client_no_auth): """Test fetching progress.""" mock_storage(hass_storage, {"done": ["hello"]}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() with patch.object(views, "STEPS", ["hello", "world"]): resp = await client.get("/api/onboarding") @@ -110,7 +110,7 @@ async def test_onboarding_progress(hass, hass_storage, aiohttp_client): assert data[1] == {"step": "world", "done": False} -async def test_onboarding_user_already_done(hass, hass_storage, aiohttp_client): +async def test_onboarding_user_already_done(hass, hass_storage, hass_client_no_auth): """Test creating a new user when user step already done.""" mock_storage(hass_storage, {"done": [views.STEP_USER]}) @@ -118,7 +118,7 @@ async def test_onboarding_user_already_done(hass, hass_storage, aiohttp_client): assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/users", @@ -134,13 +134,13 @@ async def test_onboarding_user_already_done(hass, hass_storage, aiohttp_client): assert resp.status == HTTP_FORBIDDEN -async def test_onboarding_user(hass, hass_storage, aiohttp_client): +async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): """Test creating a new user.""" assert await async_setup_component(hass, "person", {}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/users", @@ -194,14 +194,14 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): ] -async def test_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): +async def test_onboarding_user_invalid_name(hass, hass_storage, hass_client_no_auth): """Test not providing name.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/users", @@ -216,14 +216,14 @@ async def test_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): assert resp.status == 400 -async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): +async def test_onboarding_user_race(hass, hass_storage, hass_client_no_auth): """Test race condition on creating new user.""" mock_storage(hass_storage, {"done": ["hello"]}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp1 = client.post( "/api/onboarding/users", @@ -325,10 +325,16 @@ async def test_onboarding_integration_invalid_redirect_uri( client = await hass_client() - resp = await client.post( - "/api/onboarding/integration", - json={"client_id": CLIENT_ID, "redirect_uri": "http://invalid-redirect.uri"}, - ) + with patch( + "homeassistant.components.auth.indieauth.fetch_redirect_uris", return_value=[] + ): + resp = await client.post( + "/api/onboarding/integration", + json={ + "client_id": CLIENT_ID, + "redirect_uri": "http://invalid-redirect.uri", + }, + ) assert resp.status == 400 @@ -340,14 +346,16 @@ async def test_onboarding_integration_invalid_redirect_uri( assert len(user.refresh_tokens) == 1, user -async def test_onboarding_integration_requires_auth(hass, hass_storage, aiohttp_client): +async def test_onboarding_integration_requires_auth( + hass, hass_storage, hass_client_no_auth +): """Test finishing integration step.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.post( "/api/onboarding/integration", json={"client_id": CLIENT_ID} @@ -440,3 +448,40 @@ async def test_onboarding_analytics(hass, hass_storage, hass_client, hass_admin_ resp = await client.post("/api/onboarding/analytics") assert resp.status == 403 + + +async def test_onboarding_installation_type(hass, hass_storage, hass_client): + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + await async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.onboarding.views.async_get_system_info", + return_value={"installation_type": "Home Assistant Core"}, + ): + resp = await client.get("/api/onboarding/installation_type") + + assert resp.status == 200 + + resp_content = await resp.json() + assert resp_content["installation_type"] == "Home Assistant Core" + + +async def test_onboarding_installation_type_after_done(hass, hass_storage, hass_client): + """Test raising for installation type after onboarding.""" + mock_storage(hass_storage, {"done": [const.STEP_USER]}) + await async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/onboarding/installation_type") + + assert resp.status == 401 diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py index 69d69e06b7c..e1edfc2a63c 100644 --- a/tests/components/ondilo_ico/test_config_flow.py +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -30,7 +30,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -60,7 +60,7 @@ async def test_full_flow( "&scope=api" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/opengarage/__init__.py b/tests/components/opengarage/__init__.py new file mode 100644 index 00000000000..04c2572fde2 --- /dev/null +++ b/tests/components/opengarage/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenGarage integration.""" diff --git a/tests/components/opengarage/test_config_flow.py b/tests/components/opengarage/test_config_flow.py new file mode 100644 index 00000000000..ca89d07cedc --- /dev/null +++ b/tests/components/opengarage/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test the OpenGarage config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries, setup +from homeassistant.components.opengarage.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 80, + "verify_ssl": False, + } + 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( + "opengarage.OpenGarage.update_state", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +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( + "opengarage.OpenGarage.update_state", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="opengarage", + data={ + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + }, + unique_id="unique", + ) + first_entry.add_to_hass(hass) + + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 80, + "verify_ssl": False, + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_step_import(hass: HomeAssistant) -> None: + """Test when import configuring from yaml.""" + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + "ssl": False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_step_import_ssl(hass: HomeAssistant) -> None: + """Test when import configuring from yaml.""" + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + "ssl": True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + "host": "https://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 0946358548e..856d3ece298 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -39,7 +39,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client_no_auth): """Start the Home Assistant HTTP component.""" mock_component(hass, "group") mock_component(hass, "zone") @@ -50,7 +50,7 @@ def mock_client(hass, aiohttp_client): ).add_to_hass(hass) hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client_no_auth()) async def test_handle_valid_message(mock_client): diff --git a/tests/components/ozw/test_config_flow.py b/tests/components/ozw/test_config_flow.py index 6fdc86f710e..004c492bb2d 100644 --- a/tests/components/ozw/test_config_flow.py +++ b/tests/components/ozw/test_config_flow.py @@ -512,49 +512,3 @@ async def test_discovery_addon_not_installed( assert result["type"] == "form" assert result["step_id"] == "start_addon" - - -async def test_import_addon_installed( - hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon -): - """Test add-on already installed but not running on Supervisor.""" - hass.config.components.add("mqtt") - await setup.async_setup_component(hass, "persistent_notification", {}) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"usb_path": "/test/imported", "network_key": "imported123"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "on_supervisor" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"use_addon": True} - ) - - assert result["type"] == "form" - assert result["step_id"] == "start_addon" - - # the default input should be the imported data - default_input = result["data_schema"]({}) - - with patch( - "homeassistant.components.ozw.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], default_input - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == TITLE - assert result["data"] == { - "usb_path": "/test/imported", - "network_key": "imported123", - "use_addon": True, - "integration_created_addon": False, - } - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ozw/test_migration.py b/tests/components/ozw/test_migration.py deleted file mode 100644 index 076974bc48f..00000000000 --- a/tests/components/ozw/test_migration.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Test zwave to ozw migration.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components.ozw.websocket_api import ID, TYPE -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .common import setup_ozw - -from tests.common import MockConfigEntry, mock_device_registry, mock_registry - -ZWAVE_SOURCE_NODE_DEVICE_ID = "zwave_source_node_device_id" -ZWAVE_SOURCE_NODE_DEVICE_NAME = "Z-Wave Source Node Device" -ZWAVE_SOURCE_NODE_DEVICE_AREA = "Z-Wave Source Node Area" -ZWAVE_SOURCE_ENTITY = "sensor.zwave_source_node" -ZWAVE_SOURCE_NODE_UNIQUE_ID = "10-4321" -ZWAVE_BATTERY_DEVICE_ID = "zwave_battery_device_id" -ZWAVE_BATTERY_DEVICE_NAME = "Z-Wave Battery Device" -ZWAVE_BATTERY_DEVICE_AREA = "Z-Wave Battery Area" -ZWAVE_BATTERY_ENTITY = "sensor.zwave_battery_level" -ZWAVE_BATTERY_UNIQUE_ID = "36-1234" -ZWAVE_BATTERY_NAME = "Z-Wave Battery Level" -ZWAVE_BATTERY_ICON = "mdi:zwave-test-battery" -ZWAVE_POWER_DEVICE_ID = "zwave_power_device_id" -ZWAVE_POWER_DEVICE_NAME = "Z-Wave Power Device" -ZWAVE_POWER_DEVICE_AREA = "Z-Wave Power Area" -ZWAVE_POWER_ENTITY = "binary_sensor.zwave_power" -ZWAVE_POWER_UNIQUE_ID = "32-5678" -ZWAVE_POWER_NAME = "Z-Wave Power" -ZWAVE_POWER_ICON = "mdi:zwave-test-power" - - -@pytest.fixture(name="zwave_migration_data") -def zwave_migration_data_fixture(hass): - """Return mock zwave migration data.""" - zwave_source_node_device = dr.DeviceEntry( - id=ZWAVE_SOURCE_NODE_DEVICE_ID, - name_by_user=ZWAVE_SOURCE_NODE_DEVICE_NAME, - area_id=ZWAVE_SOURCE_NODE_DEVICE_AREA, - ) - zwave_source_node_entry = er.RegistryEntry( - entity_id=ZWAVE_SOURCE_ENTITY, - unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, - platform="zwave", - name="Z-Wave Source Node", - ) - zwave_battery_device = dr.DeviceEntry( - id=ZWAVE_BATTERY_DEVICE_ID, - name_by_user=ZWAVE_BATTERY_DEVICE_NAME, - area_id=ZWAVE_BATTERY_DEVICE_AREA, - ) - zwave_battery_entry = er.RegistryEntry( - entity_id=ZWAVE_BATTERY_ENTITY, - unique_id=ZWAVE_BATTERY_UNIQUE_ID, - platform="zwave", - name=ZWAVE_BATTERY_NAME, - icon=ZWAVE_BATTERY_ICON, - ) - zwave_power_device = dr.DeviceEntry( - id=ZWAVE_POWER_DEVICE_ID, - name_by_user=ZWAVE_POWER_DEVICE_NAME, - area_id=ZWAVE_POWER_DEVICE_AREA, - ) - zwave_power_entry = er.RegistryEntry( - entity_id=ZWAVE_POWER_ENTITY, - unique_id=ZWAVE_POWER_UNIQUE_ID, - platform="zwave", - name=ZWAVE_POWER_NAME, - icon=ZWAVE_POWER_ICON, - ) - zwave_migration_data = { - ZWAVE_SOURCE_NODE_UNIQUE_ID: { - "node_id": 10, - "node_instance": 1, - "device_id": zwave_source_node_device.id, - "command_class": 113, - "command_class_label": "SourceNodeId", - "value_index": 2, - "unique_id": ZWAVE_SOURCE_NODE_UNIQUE_ID, - "entity_entry": zwave_source_node_entry, - }, - ZWAVE_BATTERY_UNIQUE_ID: { - "node_id": 36, - "node_instance": 1, - "device_id": zwave_battery_device.id, - "command_class": 128, - "command_class_label": "Battery Level", - "value_index": 0, - "unique_id": ZWAVE_BATTERY_UNIQUE_ID, - "entity_entry": zwave_battery_entry, - }, - ZWAVE_POWER_UNIQUE_ID: { - "node_id": 32, - "node_instance": 1, - "device_id": zwave_power_device.id, - "command_class": 50, - "command_class_label": "Power", - "value_index": 8, - "unique_id": ZWAVE_POWER_UNIQUE_ID, - "entity_entry": zwave_power_entry, - }, - } - - mock_device_registry( - hass, - { - zwave_source_node_device.id: zwave_source_node_device, - zwave_battery_device.id: zwave_battery_device, - zwave_power_device.id: zwave_power_device, - }, - ) - mock_registry( - hass, - { - ZWAVE_SOURCE_ENTITY: zwave_source_node_entry, - ZWAVE_BATTERY_ENTITY: zwave_battery_entry, - ZWAVE_POWER_ENTITY: zwave_power_entry, - }, - ) - - return zwave_migration_data - - -@pytest.fixture(name="zwave_integration") -def zwave_integration_fixture(hass, zwave_migration_data): - """Mock the zwave integration.""" - hass.config.components.add("zwave") - zwave_config_entry = MockConfigEntry(domain="zwave", data={"usb_path": "/dev/test"}) - zwave_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.zwave.async_get_ozw_migration_data", - return_value=zwave_migration_data, - ): - yield zwave_config_entry - - -async def test_migrate_zwave(hass, migration_data, hass_ws_client, zwave_integration): - """Test the zwave to ozw migration websocket api.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - assert hass.config_entries.async_entries("zwave") - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave", "dry_run": False}) - msg = await client.receive_json() - result = msg["result"] - - migration_entity_map = { - ZWAVE_BATTERY_ENTITY: "sensor.water_sensor_6_battery_level", - } - - assert result["zwave_entity_ids"] == [ - ZWAVE_SOURCE_ENTITY, - ZWAVE_BATTERY_ENTITY, - ZWAVE_POWER_ENTITY, - ] - assert result["ozw_entity_ids"] == [ - "sensor.smart_plug_electric_w", - "sensor.water_sensor_6_battery_level", - ] - assert result["migration_entity_map"] == migration_entity_map - assert result["migrated"] is True - - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - - # check the device registry migration - - # check that the migrated entries have correct attributes - battery_entry = dev_reg.async_get_device( - identifiers={("ozw", "1.36.1")}, connections=set() - ) - assert battery_entry.name_by_user == ZWAVE_BATTERY_DEVICE_NAME - assert battery_entry.area_id == ZWAVE_BATTERY_DEVICE_AREA - power_entry = dev_reg.async_get_device( - identifiers={("ozw", "1.32.1")}, connections=set() - ) - assert power_entry.name_by_user == ZWAVE_POWER_DEVICE_NAME - assert power_entry.area_id == ZWAVE_POWER_DEVICE_AREA - - migration_device_map = { - ZWAVE_BATTERY_DEVICE_ID: battery_entry.id, - ZWAVE_POWER_DEVICE_ID: power_entry.id, - } - - assert result["migration_device_map"] == migration_device_map - - # check the entity registry migration - - # this should have been migrated and no longer present under that id - assert not ent_reg.async_is_registered("sensor.water_sensor_6_battery_level") - - # these should not have been migrated and is still in the registry - assert ent_reg.async_is_registered(ZWAVE_SOURCE_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_SOURCE_ENTITY) - assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID - assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) - assert source_entry.unique_id == ZWAVE_POWER_UNIQUE_ID - assert ent_reg.async_is_registered("sensor.smart_plug_electric_w") - - # this is the new entity_id of the ozw entity - assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) - - # check that the migrated entries have correct attributes - battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) - assert battery_entry.unique_id == "1-36-610271249" - assert battery_entry.name == ZWAVE_BATTERY_NAME - assert battery_entry.icon == ZWAVE_BATTERY_ICON - - # check that the zwave config entry has been removed - assert not hass.config_entries.async_entries("zwave") - - # Check that the zwave integration fails entry setup after migration - zwave_config_entry = MockConfigEntry(domain="zwave") - zwave_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(zwave_config_entry.entry_id) - - -async def test_migrate_zwave_dry_run( - hass, migration_data, hass_ws_client, zwave_integration -): - """Test the zwave to ozw migration websocket api dry run.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave"}) - msg = await client.receive_json() - result = msg["result"] - - migration_entity_map = { - ZWAVE_BATTERY_ENTITY: "sensor.water_sensor_6_battery_level", - } - - assert result["zwave_entity_ids"] == [ - ZWAVE_SOURCE_ENTITY, - ZWAVE_BATTERY_ENTITY, - ZWAVE_POWER_ENTITY, - ] - assert result["ozw_entity_ids"] == [ - "sensor.smart_plug_electric_w", - "sensor.water_sensor_6_battery_level", - ] - assert result["migration_entity_map"] == migration_entity_map - assert result["migrated"] is False - - ent_reg = er.async_get(hass) - - # no real migration should have been done - assert ent_reg.async_is_registered("sensor.water_sensor_6_battery_level") - assert ent_reg.async_is_registered("sensor.smart_plug_electric_w") - - assert ent_reg.async_is_registered(ZWAVE_SOURCE_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_SOURCE_ENTITY) - assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID - - assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) - battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) - assert battery_entry.unique_id == ZWAVE_BATTERY_UNIQUE_ID - - assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) - power_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) - assert power_entry.unique_id == ZWAVE_POWER_UNIQUE_ID - - # check that the zwave config entry has not been removed - assert hass.config_entries.async_entries("zwave") - - # Check that the zwave integration can be setup after dry run - zwave_config_entry = zwave_integration - with patch("openzwave.option.ZWaveOption"), patch("openzwave.network.ZWaveNetwork"): - assert await hass.config_entries.async_setup(zwave_config_entry.entry_id) - - -async def test_migrate_zwave_not_setup(hass, migration_data, hass_ws_client): - """Test the zwave to ozw migration websocket without zwave setup.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave"}) - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "zwave_not_loaded" - assert msg["error"]["message"] == "Integration zwave is not loaded" diff --git a/tests/components/panasonic_viera/test_init.py b/tests/components/panasonic_viera/test_init.py index 0f30e315683..e3fc74133d3 100644 --- a/tests/components/panasonic_viera/test_init.py +++ b/tests/components/panasonic_viera/test_init.py @@ -1,5 +1,5 @@ """Test the Panasonic Viera setup process.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from homeassistant.components.panasonic_viera.const import ( ATTR_DEVICE_INFO, @@ -185,14 +185,21 @@ async def test_setup_entry_unencrypted_missing_device_info_none(hass): async def test_setup_config_flow_initiated(hass): """Test if config flow is initiated in setup.""" - assert ( - await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "0.0.0.0"}}, + mock_remote = get_mock_remote() + mock_remote.get_device_info = Mock(side_effect=OSError) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + assert ( + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: "0.0.0.0"}}, + ) + is True ) - is True - ) assert len(hass.config_entries.flow.async_progress()) == 1 diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 098d86785ab..36aa06443df 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -85,6 +85,8 @@ DEFAULT_DELIVERY_RESPONSE = { ], } +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + @pytest.mark.usefixtures("hass_storage") class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): @@ -161,7 +163,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): async def _enable_all_sensors(self): """Enable all sensors of the Picnic integration.""" # Enable the sensors - for sensor_type in SENSOR_TYPES.keys(): + for sensor_type in SENSOR_KEYS: updated_entry = self.entity_registry.async_update_entity( f"sensor.picnic_{sensor_type}", disabled_by=None ) diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index a9af91c9f6f..3ffb2bb95d5 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -2,12 +2,21 @@ from os import path from unittest.mock import patch +import pytest + from homeassistant import config as hass_config, setup from homeassistant.components.ping import DOMAIN from homeassistant.const import SERVICE_RELOAD -async def test_reload(hass): +@pytest.fixture +def mock_ping(): + """Mock icmplib.ping.""" + with patch("homeassistant.components.ping.icmp_ping"): + yield + + +async def test_reload(hass, mock_ping): """Verify we can reload trend sensors.""" await setup.async_setup_component( diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 6ed8eaaa94a..cdd0d4dff3e 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -78,18 +78,54 @@ def library_movies_all_fixture(): return load_fixture("plex/library_movies_all.xml") +@pytest.fixture(name="library_movies_metadata", scope="session") +def library_movies_metadata_fixture(): + """Load payload for metadata in the movies library and return it.""" + return load_fixture("plex/library_movies_metadata.xml") + + +@pytest.fixture(name="library_movies_collections", scope="session") +def library_movies_collections_fixture(): + """Load payload for collections in the movies library and return it.""" + return load_fixture("plex/library_movies_collections.xml") + + @pytest.fixture(name="library_tvshows_all", scope="session") def library_tvshows_all_fixture(): """Load payload for all items in the tvshows library and return it.""" return load_fixture("plex/library_tvshows_all.xml") +@pytest.fixture(name="library_tvshows_metadata", scope="session") +def library_tvshows_metadata_fixture(): + """Load payload for metadata in the TV shows library and return it.""" + return load_fixture("plex/library_tvshows_metadata.xml") + + +@pytest.fixture(name="library_tvshows_collections", scope="session") +def library_tvshows_collections_fixture(): + """Load payload for collections in the TV shows library and return it.""" + return load_fixture("plex/library_tvshows_collections.xml") + + @pytest.fixture(name="library_music_all", scope="session") def library_music_all_fixture(): """Load payload for all items in the music library and return it.""" return load_fixture("plex/library_music_all.xml") +@pytest.fixture(name="library_music_metadata", scope="session") +def library_music_metadata_fixture(): + """Load payload for metadata in the music library and return it.""" + return load_fixture("plex/library_music_metadata.xml") + + +@pytest.fixture(name="library_music_collections", scope="session") +def library_music_collections_fixture(): + """Load payload for collections in the music library and return it.""" + return load_fixture("plex/library_music_collections.xml") + + @pytest.fixture(name="library_movies_sort", scope="session") def library_movies_sort_fixture(): """Load sorting payload for movie library and return it.""" @@ -120,6 +156,18 @@ def library_fixture(): return load_fixture("plex/library.xml") +@pytest.fixture(name="library_movies_size", scope="session") +def library_movies_size_fixture(): + """Load movie library size payload and return it.""" + return load_fixture("plex/library_movies_size.xml") + + +@pytest.fixture(name="library_music_size", scope="session") +def library_music_size_fixture(): + """Load music library size payload and return it.""" + return load_fixture("plex/library_music_size.xml") + + @pytest.fixture(name="library_tvshows_size", scope="session") def library_tvshows_size_fixture(): """Load tvshow library size payload and return it.""" @@ -352,10 +400,16 @@ def mock_plex_calls( library, library_sections, library_movies_all, + library_movies_collections, + library_movies_metadata, library_movies_sort, library_music_all, + library_music_collections, + library_music_metadata, library_music_sort, library_tvshows_all, + library_tvshows_collections, + library_tvshows_metadata, library_tvshows_sort, media_1, media_30, @@ -396,6 +450,32 @@ def mock_plex_calls( requests_mock.get(f"{url}/library/sections/2/all", text=library_tvshows_all) requests_mock.get(f"{url}/library/sections/3/all", text=library_music_all) + requests_mock.get( + f"{url}/library/sections/1/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_movies_metadata, + ) + requests_mock.get( + f"{url}/library/sections/2/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_tvshows_metadata, + ) + requests_mock.get( + f"{url}/library/sections/3/all?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_music_metadata, + ) + + requests_mock.get( + f"{url}/library/sections/1/collections?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_movies_collections, + ) + requests_mock.get( + f"{url}/library/sections/2/collections?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_tvshows_collections, + ) + requests_mock.get( + f"{url}/library/sections/3/collections?includeMeta=1&includeAdvanced=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0", + text=library_music_collections, + ) + requests_mock.get(f"{url}/library/metadata/200/children", text=children_200) requests_mock.get(f"{url}/library/metadata/300/children", text=children_300) requests_mock.get(f"{url}/library/metadata/300/allLeaves", text=grandchildren_300) diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index 4892262fc32..d4ea73f6a97 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -1,15 +1,86 @@ """Tests for Plex media browser.""" +from unittest.mock import patch + from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ) from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER -from homeassistant.components.plex.media_browser import SPECIAL_METHODS from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT from .const import DEFAULT_DATA +class MockPlexShow: + """Mock a plexapi Season instance.""" + + ratingKey = 30 + title = "TV Show" + type = "show" + + def __iter__(self): + """Iterate over episodes.""" + yield MockPlexSeason() + + +class MockPlexSeason: + """Mock a plexapi Season instance.""" + + ratingKey = 20 + title = "Season 1" + type = "season" + year = 2021 + + def __iter__(self): + """Iterate over episodes.""" + yield MockPlexEpisode() + + +class MockPlexEpisode: + """Mock a plexapi Episode instance.""" + + ratingKey = 10 + title = "Episode 1" + grandparentTitle = "TV Show" + seasonEpisode = "s01e01" + type = "episode" + + +class MockPlexArtist: + """Mock a plexapi Artist instance.""" + + ratingKey = 300 + title = "Artist" + type = "artist" + + def __iter__(self): + """Iterate over albums.""" + yield MockPlexAlbum() + + +class MockPlexAlbum: + """Mock a plexapi Album instance.""" + + ratingKey = 200 + parentTitle = "Artist" + title = "Album" + type = "album" + year = 2019 + + def __iter__(self): + """Iterate over tracks.""" + yield MockPlexTrack() + + +class MockPlexTrack: + """Mock a plexapi Track instance.""" + + index = 1 + ratingKey = 100 + title = "Track 1" + type = "track" + + async def test_browse_media( hass, hass_ws_client, @@ -58,15 +129,13 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER] - # Library Sections + Special Sections + Playlists - assert ( - len(result["children"]) - == len(mock_plex_server.library.sections()) + len(SPECIAL_METHODS) + 1 - ) + # Library Sections + On Deck + Recently Added + Playlists + assert len(result["children"]) == len(mock_plex_server.library.sections()) + 3 + music = next(iter(x for x in result["children"] if x["title"] == "Music")) tvshows = next(iter(x for x in result["children"] if x["title"] == "TV Shows")) playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists")) - special_keys = list(SPECIAL_METHODS.keys()) + special_keys = ["On Deck", "Recently Added"] # Browse into a special folder (server) msg_id += 1 @@ -144,12 +213,128 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" result_id = int(result[ATTR_MEDIA_CONTENT_ID]) - assert len(result["children"]) == len( - mock_plex_server.library.sectionByID(result_id).all() - ) + len(SPECIAL_METHODS) + # All items in section + On Deck + Recently Added + assert ( + len(result["children"]) + == len(mock_plex_server.library.sectionByID(result_id).all()) + 2 + ) # Browse into a Plex TV show msg_id += 1 + mock_show = MockPlexShow() + mock_season = next(iter(mock_show)) + with patch.object( + mock_plex_server, "fetch_item", return_value=mock_show + ) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ + ATTR_MEDIA_CONTENT_TYPE + ], + ATTR_MEDIA_CONTENT_ID: str( + result["children"][-1][ATTR_MEDIA_CONTENT_ID] + ), + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == msg_id + assert msg["type"] == TYPE_RESULT + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result["title"] == mock_plex_server.fetch_item(result_id).title + assert result["children"][0]["title"] == f"{mock_season.title} ({mock_season.year})" + + # Browse into a Plex TV show season + msg_id += 1 + mock_episode = next(iter(mock_season)) + with patch.object( + mock_plex_server, "fetch_item", return_value=mock_season + ) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][0][ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: str( + result["children"][0][ATTR_MEDIA_CONTENT_ID] + ), + } + ) + + msg = await websocket_client.receive_json() + + assert mock_fetch.called + assert msg["id"] == msg_id + assert msg["type"] == TYPE_RESULT + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "season" + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result["title"] == f"{mock_season.title} ({mock_season.year})" + assert ( + result["children"][0]["title"] + == f"{mock_episode.seasonEpisode.upper()} - {mock_episode.title}" + ) + + # Browse into a Plex music library + msg_id += 1 + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: music[ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: str(music[ATTR_MEDIA_CONTENT_ID]), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + result = msg["result"] + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" + assert result["title"] == "Music" + + # Browse into a Plex artist + msg_id += 1 + mock_artist = MockPlexArtist() + mock_album = next(iter(MockPlexArtist())) + mock_track = next(iter(MockPlexAlbum())) + with patch.object( + mock_plex_server, "fetch_item", return_value=mock_artist + ) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ + ATTR_MEDIA_CONTENT_TYPE + ], + ATTR_MEDIA_CONTENT_ID: str( + result["children"][-1][ATTR_MEDIA_CONTENT_ID] + ), + } + ) + msg = await websocket_client.receive_json() + + assert mock_fetch.called + assert msg["success"] + result = msg["result"] + result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + assert result[ATTR_MEDIA_CONTENT_TYPE] == "artist" + assert result["title"] == mock_artist.title + assert result["children"][0]["title"] == f"{mock_album.title} ({mock_album.year})" + + # Browse into a Plex album + msg_id += 1 await websocket_client.send_json( { "id": msg_id, @@ -159,15 +344,17 @@ async def test_browse_media( ATTR_MEDIA_CONTENT_ID: str(result["children"][-1][ATTR_MEDIA_CONTENT_ID]), } ) - msg = await websocket_client.receive_json() - assert msg["id"] == msg_id - assert msg["type"] == TYPE_RESULT + assert msg["success"] result = msg["result"] - assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" result_id = int(result[ATTR_MEDIA_CONTENT_ID]) - assert result["title"] == mock_plex_server.fetch_item(result_id).title + assert result[ATTR_MEDIA_CONTENT_TYPE] == "album" + assert ( + result["title"] + == f"{mock_artist.title} - {mock_album.title} ({mock_album.year})" + ) + assert result["children"][0]["title"] == f"{mock_track.index}. {mock_track.title}" # Browse into a non-existent TV season unknown_key = 99999999999999 @@ -211,3 +398,26 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists" result_id = result[ATTR_MEDIA_CONTENT_ID] + + # Browse recently added items + msg_id += 1 + mock_items = [MockPlexAlbum(), MockPlexEpisode(), MockPlexSeason(), MockPlexTrack()] + with patch("plexapi.library.Library.search", return_value=mock_items) as mock_fetch: + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: "server", + ATTR_MEDIA_CONTENT_ID: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[1]}", + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" + result_id = result[ATTR_MEDIA_CONTENT_ID] + for child in result["children"]: + assert child["media_content_type"] in ["album", "episode"] + assert child["media_content_type"] not in ["season", "track"] diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 45904588a10..9f9e4e1cdfb 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -509,7 +509,7 @@ async def test_external_timed_out(hass, current_request_with_host): assert result["reason"] == "token_request_timeout" -async def test_callback_view(hass, aiohttp_client, current_request_with_host): +async def test_callback_view(hass, hass_client_no_auth, current_request_with_host): """Test callback view.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -525,7 +525,7 @@ async def test_callback_view(hass, aiohttp_client, current_request_with_host): ) assert result["type"] == "external" - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' resp = await client.get(forward_url) diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 39a2901e72d..0e87f25850f 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -1,11 +1,14 @@ """Tests for Plex sensors.""" -from datetime import timedelta +from datetime import datetime, timedelta +from unittest.mock import patch import requests.exceptions +from homeassistant.components.plex.const import PLEX_UPDATE_LIBRARY_SIGNAL from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt from .helpers import trigger_plex_update, wait_for_debouncer @@ -14,6 +17,51 @@ from tests.common import async_fire_time_changed LIBRARY_UPDATE_PAYLOAD = {"StatusNotification": [{"title": "Library scan complete"}]} +TIMESTAMP = datetime(2021, 9, 1) + + +class MockPlexMedia: + """Minimal mock of base plexapi media object.""" + + key = "key" + addedAt = str(TIMESTAMP) + listType = "video" + year = 2021 + + +class MockPlexClip(MockPlexMedia): + """Minimal mock of plexapi clip object.""" + + type = "clip" + title = "Clip 1" + + +class MockPlexMovie(MockPlexMedia): + """Minimal mock of plexapi movie object.""" + + type = "movie" + title = "Movie 1" + + +class MockPlexMusic(MockPlexMedia): + """Minimal mock of plexapi album object.""" + + listType = "audio" + type = "album" + title = "Album" + parentTitle = "Artist" + + +class MockPlexTVEpisode(MockPlexMedia): + """Minimal mock of plexapi episode object.""" + + type = "episode" + title = "Episode 5" + grandparentTitle = "TV Show" + seasonEpisode = "s01e05" + year = None + parentYear = 2021 + async def test_library_sensor_values( hass, @@ -21,11 +69,18 @@ async def test_library_sensor_values( setup_plex_server, mock_websocket, requests_mock, + library_movies_size, + library_music_size, library_tvshows_size, library_tvshows_size_episodes, library_tvshows_size_seasons, ): """Test the library sensors.""" + requests_mock.get( + "/library/sections/1/all?includeCollections=0", + text=library_movies_size, + ) + requests_mock.get( "/library/sections/2/all?includeCollections=0&type=2", text=library_tvshows_size, @@ -39,7 +94,12 @@ async def test_library_sensor_values( text=library_tvshows_size_episodes, ) - await setup_plex_server() + requests_mock.get( + "/library/sections/3/all?includeCollections=0", + text=library_music_size, + ) + + mock_plex_server = await setup_plex_server() await wait_for_debouncer(hass) activity_sensor = hass.states.get("sensor.plex_plex_server_1") @@ -59,12 +119,20 @@ async def test_library_sensor_values( hass, dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - await hass.async_block_till_done() + + media = [MockPlexTVEpisode()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") assert library_tv_sensor.state == "10" assert library_tv_sensor.attributes["seasons"] == 1 assert library_tv_sensor.attributes["shows"] == 1 + assert ( + library_tv_sensor.attributes["last_added_item"] + == "TV Show - S01E05 - Episode 5" + ) + assert library_tv_sensor.attributes["last_added_timestamp"] == str(TIMESTAMP) # Handle `requests` exception requests_mock.get( @@ -89,7 +157,8 @@ async def test_library_sensor_values( trigger_plex_update( mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD ) - await hass.async_block_till_done() + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") assert library_tv_sensor.state == "10" @@ -105,3 +174,63 @@ async def test_library_sensor_values( library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") assert library_tv_sensor.state == STATE_UNAVAILABLE + + # Test movie library sensor + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_tv_shows", disabled_by="user" + ) + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_movies", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + + media = [MockPlexMovie()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() + + library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") + assert library_movies_sensor.state == "1" + assert library_movies_sensor.attributes["last_added_item"] == "Movie 1 (2021)" + assert library_movies_sensor.attributes["last_added_timestamp"] == str(TIMESTAMP) + + # Test with clip + media = [MockPlexClip()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + async_dispatcher_send( + hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) + ) + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + + library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") + assert library_movies_sensor.attributes["last_added_item"] == "Clip 1" + + # Test music library sensor + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_movies", disabled_by="user" + ) + entity_registry.async_update_entity( + entity_id="sensor.plex_server_1_library_music", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + + media = [MockPlexMusic()] + with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + await hass.async_block_till_done() + + library_music_sensor = hass.states.get("sensor.plex_server_1_library_music") + assert library_music_sensor.state == "1" + assert library_music_sensor.attributes["artists"] == 1 + assert library_music_sensor.attributes["albums"] == 1 + assert library_music_sensor.attributes["last_added_item"] == "Artist - Album (2021)" + assert library_music_sensor.attributes["last_added_timestamp"] == str(TIMESTAMP) diff --git a/tests/components/ps4/conftest.py b/tests/components/ps4/conftest.py index 155f1c6d5dd..5bb27012b18 100644 --- a/tests/components/ps4/conftest.py +++ b/tests/components/ps4/conftest.py @@ -1,6 +1,7 @@ """Test configuration for PS4.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from pyps4_2ndscreen.ddp import DEFAULT_UDP_PORT, DDPProtocol import pytest @@ -25,6 +26,19 @@ def patch_get_status(): yield mock_get_status +@pytest.fixture +def mock_ddp_endpoint(): + """Mock pyps4_2ndscreen.ddp.async_create_ddp_endpoint.""" + protocol = DDPProtocol() + protocol._local_port = DEFAULT_UDP_PORT + protocol._transport = MagicMock() + with patch( + "homeassistant.components.ps4.async_create_ddp_endpoint", + return_value=(None, protocol), + ): + yield + + @pytest.fixture(autouse=True) -def patch_io(patch_load_json, patch_save_json, patch_get_status): +def patch_io(patch_load_json, patch_save_json, patch_get_status, mock_ddp_endpoint): """Prevent PS4 doing I/O.""" diff --git a/tests/components/push/test_camera.py b/tests/components/push/test_camera.py index 644db2b9dd5..d4759350341 100644 --- a/tests/components/push/test_camera.py +++ b/tests/components/push/test_camera.py @@ -9,7 +9,7 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_bad_posting(hass, aiohttp_client): +async def test_bad_posting(hass, hass_client_no_auth): """Test that posting to wrong api endpoint fails.""" await async_process_ha_core_config( hass, @@ -30,7 +30,7 @@ async def test_bad_posting(hass, aiohttp_client): await hass.async_block_till_done() assert hass.states.get("camera.config_test") is not None - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() # missing file async with client.post("/api/webhook/camera.config_test") as resp: @@ -40,7 +40,7 @@ async def test_bad_posting(hass, aiohttp_client): assert camera_state.state == "idle" # no file supplied we are still idle -async def test_posting_url(hass, aiohttp_client): +async def test_posting_url(hass, hass_client_no_auth): """Test that posting to api endpoint works.""" await async_process_ha_core_config( hass, @@ -60,7 +60,7 @@ async def test_posting_url(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() files = {"image": io.BytesIO(b"fake")} # initial state diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index 22f32983055..b55202d93b3 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.recollect_waste import ( CONF_SERVICE_ID, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_FRIENDLY_NAME from tests.common import MockConfigEntry @@ -81,22 +81,6 @@ async def test_show_form(hass): assert result["step_id"] == "user" -async def test_step_import(hass): - """Test that the user step works.""" - conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - with patch( - "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345, 12345" - assert result["data"] == {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - async def test_step_user(hass): """Test that the user step works.""" conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 2a29513a88e..e7786307b69 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -30,9 +30,11 @@ async def async_setup_recorder_instance( hass: HomeAssistant, config: ConfigType | None = None ) -> Recorder: """Setup and return recorder instance.""" # noqa: D401 - stats = recorder.Recorder.async_hourly_statistics if enable_statistics else None + stats = ( + recorder.Recorder.async_periodic_statistics if enable_statistics else None + ) with patch( - "homeassistant.components.recorder.Recorder.async_hourly_statistics", + "homeassistant.components.recorder.Recorder.async_periodic_statistics", side_effect=stats, autospec=True, ): diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index b2940f2bb39..67a666c934f 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -48,13 +48,24 @@ def test_get_states(hass_recorder): wait_recording_done(hass) - # Get states returns everything before POINT + # Get states returns everything before POINT for all entities for state1, state2 in zip( states, sorted(history.get_states(hass, future), key=lambda state: state.entity_id), ): assert state1 == state2 + # Get states returns everything before POINT for tested entities + entities = [f"test.point_in_time_{i % 5}" for i in range(5)] + for state1, state2 in zip( + states, + sorted( + history.get_states(hass, future, entities), + key=lambda state: state.entity_id, + ), + ): + assert state1 == state2 + # Test get_state here because we have a DB setup assert states[0] == history.get_state(hass, future, states[0].entity_id) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index fa0e8b7349b..e41a0da34ba 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -700,41 +700,41 @@ def test_auto_statistics(hass_recorder): tz = dt_util.get_time_zone("Europe/Copenhagen") dt_util.set_default_time_zone(tz) - # Statistics is scheduled to happen at *:12am every hour. Exercise this behavior by + # Statistics is scheduled to happen every 5 minutes. Exercise this behavior by # firing time changed events and advancing the clock around this time. Pick an # arbitrary year in the future to avoid boundary conditions relative to the current # date. # - # The clock is started at 4:15am then advanced forward below + # The clock is started at 4:16am then advanced forward below now = dt_util.utcnow() - test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) + test_time = datetime(now.year + 2, 1, 1, 4, 16, 0, tzinfo=tz) run_tasks_at_time(hass, test_time) with patch( "homeassistant.components.recorder.statistics.compile_statistics", return_value=True, ) as compile_statistics: - # Advance one hour, and the statistics task should run - test_time = test_time + timedelta(hours=1) + # Advance 5 minutes, and the statistics task should run + test_time = test_time + timedelta(minutes=5) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 compile_statistics.reset_mock() - # Advance one hour, and the statistics task should run again - test_time = test_time + timedelta(hours=1) + # Advance 5 minutes, and the statistics task should run again + test_time = test_time + timedelta(minutes=5) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 compile_statistics.reset_mock() - # Advance less than one full hour. The task should not run. - test_time = test_time + timedelta(minutes=50) + # Advance less than 5 minutes. The task should not run. + test_time = test_time + timedelta(minutes=3) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 0 - # Advance to the next hour, and the statistics task should run again - test_time = test_time + timedelta(hours=1) + # Advance 5 minutes, and the statistics task should run again + test_time = test_time + timedelta(minutes=5) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 @@ -754,8 +754,8 @@ def test_statistics_runs_initiated(hass_recorder): assert len(statistics_runs) == 1 last_run = process_timestamp(statistics_runs[0].start) assert process_timestamp(last_run) == now.replace( - minute=0, second=0, microsecond=0 - ) - timedelta(hours=1) + minute=now.minute - now.minute % 5, second=0, microsecond=0 + ) - timedelta(minutes=5) def test_compile_missing_statistics(tmpdir): @@ -776,7 +776,7 @@ def test_compile_missing_statistics(tmpdir): statistics_runs = list(session.query(StatisticsRuns)) assert len(statistics_runs) == 1 last_run = process_timestamp(statistics_runs[0].start) - assert last_run == now - timedelta(hours=1) + assert last_run == now - timedelta(minutes=5) wait_recording_done(hass) wait_recording_done(hass) @@ -795,7 +795,7 @@ def test_compile_missing_statistics(tmpdir): with session_scope(hass=hass) as session: statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 2 + assert len(statistics_runs) == 13 # 12 5-minute runs last_run = process_timestamp(statistics_runs[1].start) assert last_run == now diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index ae7510ee979..42122983007 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -65,7 +65,7 @@ async def test_schema_update_calls(hass): assert await recorder.async_migration_in_progress(hass) is False update.assert_has_calls( [ - call(hass.data[DATA_INSTANCE].engine, ANY, version + 1, 0) + call(hass.data[DATA_INSTANCE], ANY, version + 1, 0) for version in range(0, models.SCHEMA_VERSION) ] ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 40ad71096c1..0e66beecd87 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -44,6 +44,7 @@ async def test_purge_old_states( events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 6 + assert "test.recorder2" in instance._old_states purge_before = dt_util.utcnow() - timedelta(days=4) @@ -51,6 +52,7 @@ async def test_purge_old_states( finished = purge_old_data(instance, purge_before, repack=False) assert not finished assert states.count() == 2 + assert "test.recorder2" in instance._old_states states_after_purge = session.query(States) assert states_after_purge[1].old_state_id == states_after_purge[0].state_id @@ -59,6 +61,28 @@ async def test_purge_old_states( finished = purge_old_data(instance, purge_before, repack=False) assert finished assert states.count() == 2 + assert "test.recorder2" in instance._old_states + + # run purge_old_data again + purge_before = dt_util.utcnow() + finished = purge_old_data(instance, purge_before, repack=False) + assert not finished + assert states.count() == 0 + assert "test.recorder2" not in instance._old_states + + # Add some more states + await _add_test_states(hass, instance) + + # make sure we start with 6 states + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 6 + assert states[0].old_state_id is None + assert states[-1].old_state_id == states[-2].state_id + + events = session.query(Events).filter(Events.event_type == "state_changed") + assert events.count() == 6 + assert "test.recorder2" in instance._old_states async def test_purge_old_states_encouters_database_corruption( @@ -872,45 +896,27 @@ async def _add_test_states(hass: HomeAssistant, instance: recorder.Recorder): eleven_days_ago = utcnow - timedelta(days=11) attributes = {"test_attr": 5, "test_attr_10": "nice"} - await hass.async_block_till_done() - await async_wait_recording_done(hass, instance) + async def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.async_set(entity_id, state, **kwargs) + await hass.async_block_till_done() + await async_wait_recording_done(hass, instance) - with recorder.session_scope(hass=hass) as session: - old_state_id = None - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - state = "autopurgeme" - elif event_id < 4: - timestamp = five_days_ago - state = "purgeme" - else: - timestamp = utcnow - state = "dontpurgeme" + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = f"autopurgeme_{event_id}" + elif event_id < 4: + timestamp = five_days_ago + state = f"purgeme_{event_id}" + else: + timestamp = utcnow + state = f"dontpurgeme_{event_id}" - event = Events( - event_type="state_changed", - event_data="{}", - origin="LOCAL", - created=timestamp, - time_fired=timestamp, - ) - session.add(event) - session.flush() - state = States( - entity_id="test.recorder2", - domain="sensor", - state=state, - attributes=json.dumps(attributes), - last_changed=timestamp, - last_updated=timestamp, - created=timestamp, - event_id=event.event_id, - old_state_id=old_state_id, - ) - session.add(state) - session.flush() - old_state_id = state.state_id + with patch( + "homeassistant.components.recorder.dt_util.utcnow", return_value=timestamp + ): + await set_state("test.recorder2", state, attributes=attributes) async def _add_test_events(hass: HomeAssistant, instance: recorder.Recorder): diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 0580460a537..d3496407949 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -9,7 +9,7 @@ from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import ( - Statistics, + StatisticsShortTerm, process_timestamp_to_utc_isoformat, ) from homeassistant.components.recorder.statistics import ( @@ -34,17 +34,18 @@ def test_compile_hourly_statistics(hass_recorder): assert dict(states) == dict(hist) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): - stats = statistics_during_period(hass, zero, **kwargs) + stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - recorder.do_adhoc_statistics(period="hourly", start=zero) - recorder.do_adhoc_statistics(period="hourly", start=four) + recorder.do_adhoc_statistics(start=zero) + recorder.do_adhoc_statistics(start=four) wait_recording_done(hass) expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), @@ -55,6 +56,7 @@ def test_compile_hourly_statistics(hass_recorder): expected_2 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), + "end": process_timestamp_to_utc_isoformat(four + timedelta(minutes=5)), "mean": approx(20.0), "min": approx(20.0), "max": approx(20.0), @@ -72,13 +74,17 @@ def test_compile_hourly_statistics(hass_recorder): ] # Test statistics_during_period - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} - stats = statistics_during_period(hass, zero, statistic_ids=["sensor.test2"]) + stats = statistics_during_period( + hass, zero, statistic_ids=["sensor.test2"], period="5minute" + ) assert stats == {"sensor.test2": expected_stats2} - stats = statistics_during_period(hass, zero, statistic_ids=["sensor.test3"]) + stats = statistics_during_period( + hass, zero, statistic_ids=["sensor.test3"], period="5minute" + ) assert stats == {} # Test get_last_statistics @@ -101,21 +107,29 @@ def test_compile_hourly_statistics(hass_recorder): @pytest.fixture def mock_sensor_statistics(): """Generate some fake statistics.""" - sensor_stats = { - "meta": {"unit_of_measurement": "dogs", "has_mean": True, "has_sum": False}, - "stat": {}, - } - def get_fake_stats(): + def sensor_stats(entity_id, start): + """Generate fake statistics.""" return { - "sensor.test1": sensor_stats, - "sensor.test2": sensor_stats, - "sensor.test3": sensor_stats, + "meta": { + "statistic_id": entity_id, + "unit_of_measurement": "dogs", + "has_mean": True, + "has_sum": False, + }, + "stat": ({"start": start},), } + def get_fake_stats(_hass, start, _end): + return [ + sensor_stats("sensor.test1", start), + sensor_stats("sensor.test2", start), + sensor_stats("sensor.test3", start), + ] + with patch( "homeassistant.components.sensor.recorder.compile_statistics", - return_value=get_fake_stats(), + side_effect=get_fake_stats, ): yield @@ -124,42 +138,40 @@ def mock_sensor_statistics(): def mock_from_stats(): """Mock out Statistics.from_stats.""" counter = 0 - real_from_stats = Statistics.from_stats + real_from_stats = StatisticsShortTerm.from_stats - def from_stats(metadata_id, start, stats): + def from_stats(metadata_id, stats): nonlocal counter if counter == 0 and metadata_id == 2: counter += 1 return None - return real_from_stats(metadata_id, start, stats) + return real_from_stats(metadata_id, stats) with patch( - "homeassistant.components.recorder.statistics.Statistics.from_stats", + "homeassistant.components.recorder.statistics.StatisticsShortTerm.from_stats", side_effect=from_stats, autospec=True, ): yield -def test_compile_hourly_statistics_exception( +def test_compile_periodic_statistics_exception( hass_recorder, mock_sensor_statistics, mock_from_stats ): - """Test exception handling when compiling hourly statistics.""" - - def mock_from_stats(): - raise ValueError + """Test exception handling when compiling periodic statistics.""" hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) now = dt_util.utcnow() - recorder.do_adhoc_statistics(period="hourly", start=now) - recorder.do_adhoc_statistics(period="hourly", start=now + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=now) + recorder.do_adhoc_statistics(start=now + timedelta(minutes=5)) wait_recording_done(hass) expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(now), + "end": process_timestamp_to_utc_isoformat(now + timedelta(minutes=5)), "mean": None, "min": None, "max": None, @@ -169,7 +181,8 @@ def test_compile_hourly_statistics_exception( } expected_2 = { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(now + timedelta(minutes=5)), + "end": process_timestamp_to_utc_isoformat(now + timedelta(minutes=10)), "mean": None, "min": None, "max": None, @@ -189,7 +202,7 @@ def test_compile_hourly_statistics_exception( {**expected_2, "statistic_id": "sensor.test3"}, ] - stats = statistics_during_period(hass, now) + stats = statistics_during_period(hass, now, period="5minute") assert stats == { "sensor.test1": expected_stats1, "sensor.test2": expected_stats2, @@ -217,16 +230,17 @@ def test_rename_entity(hass_recorder): assert dict(states) == dict(hist) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): - stats = statistics_during_period(hass, zero, **kwargs) + stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) expected_1 = { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(14.915254237288135), "min": approx(10.0), "max": approx(20.0), @@ -244,13 +258,13 @@ def test_rename_entity(hass_recorder): {**expected_1, "statistic_id": "sensor.test99"}, ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") 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) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} @@ -270,7 +284,7 @@ def test_statistics_duplicated(hass_recorder, caplog): with patch( "homeassistant.components.sensor.recorder.compile_statistics" ) as compile_statistics: - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert compile_statistics.called compile_statistics.reset_mock() @@ -278,7 +292,7 @@ def test_statistics_duplicated(hass_recorder, caplog): assert "Statistics already compiled" not in caplog.text caplog.clear() - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert not compile_statistics.called compile_statistics.reset_mock() @@ -317,10 +331,10 @@ def record_states(hass): return hass.states.get(entity_id) zero = dt_util.utcnow() - one = zero + timedelta(minutes=1) - two = one + timedelta(minutes=15) - three = two + timedelta(minutes=30) - four = three + timedelta(minutes=15) + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=15 * 5) + three = two + timedelta(seconds=30 * 5) + four = three + timedelta(seconds=15 * 5) states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index cb54f0404b9..f193993ffe5 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -149,15 +149,17 @@ def test_setup_connection_for_dialect_sqlite(): util.setup_connection_for_dialect("sqlite", dbapi_connection, True) - assert len(execute_mock.call_args_list) == 2 + assert len(execute_mock.call_args_list) == 3 assert execute_mock.call_args_list[0][0][0] == "PRAGMA journal_mode=WAL" assert execute_mock.call_args_list[1][0][0] == "PRAGMA cache_size = -8192" + assert execute_mock.call_args_list[2][0][0] == "PRAGMA foreign_keys=ON" execute_mock.reset_mock() util.setup_connection_for_dialect("sqlite", dbapi_connection, False) - assert len(execute_mock.call_args_list) == 1 + assert len(execute_mock.call_args_list) == 2 assert execute_mock.call_args_list[0][0][0] == "PRAGMA cache_size = -8192" + assert execute_mock.call_args_list[1][0][0] == "PRAGMA foreign_keys=ON" def test_basic_sanity_check(hass_recorder): diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py new file mode 100644 index 00000000000..e60659aaab2 --- /dev/null +++ b/tests/components/recorder/test_websocket_api.py @@ -0,0 +1,229 @@ +"""The tests for sensor recorder platform.""" +# pylint: disable=protected-access,invalid-name +from datetime import timedelta + +import pytest +from pytest import approx + +from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .common import trigger_db_commit + +from tests.common import init_recorder_component + +POWER_SENSOR_ATTRIBUTES = { + "device_class": "power", + "state_class": "measurement", + "unit_of_measurement": "kW", +} +TEMPERATURE_SENSOR_ATTRIBUTES = { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", +} + + +async def test_validate_statistics(hass, hass_ws_client): + """Test validate_statistics can be called.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + # No statistics, no state - empty response + await hass.async_add_executor_job(init_recorder_component, hass) + client = await hass_ws_client() + await assert_validation_result(client, {}) + + +async def test_clear_statistics(hass, hass_ws_client): + """Test removing statistics.""" + now = dt_util.utcnow() + + units = METRIC_SYSTEM + attributes = POWER_SENSOR_ATTRIBUTES + state = 10 + value = 10000 + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test1", state, attributes=attributes) + hass.states.async_set("sensor.test2", state * 2, attributes=attributes) + hass.states.async_set("sensor.test3", state * 3, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + expected_response = { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value), + "min": approx(value), + "max": approx(value), + "last_reset": None, + "state": None, + "sum": None, + } + ], + "sensor.test2": [ + { + "statistic_id": "sensor.test2", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value * 2), + "min": approx(value * 2), + "max": approx(value * 2), + "last_reset": None, + "state": None, + "sum": None, + } + ], + "sensor.test3": [ + { + "statistic_id": "sensor.test3", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value * 3), + "min": approx(value * 3), + "max": approx(value * 3), + "last_reset": None, + "state": None, + "sum": None, + } + ], + } + assert response["result"] == expected_response + + await client.send_json( + { + "id": 2, + "type": "recorder/clear_statistics", + "statistic_ids": ["sensor.test"], + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json( + { + "id": 3, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_response + + await client.send_json( + { + "id": 4, + "type": "recorder/clear_statistics", + "statistic_ids": ["sensor.test1", "sensor.test3"], + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json( + { + "id": 5, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"sensor.test2": expected_response["sensor.test2"]} + + +@pytest.mark.parametrize("new_unit", ["dogs", None]) +async def test_update_statistics_metadata(hass, hass_ws_client, new_unit): + """Test removing statistics.""" + now = dt_util.utcnow() + + units = METRIC_SYSTEM + attributes = POWER_SENSOR_ATTRIBUTES + state = 10 + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test", state, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + + hass.data[DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + + await client.send_json({"id": 1, "type": "history/list_statistic_ids"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + {"statistic_id": "sensor.test", "unit_of_measurement": "W"} + ] + + await client.send_json( + { + "id": 2, + "type": "recorder/update_statistics_metadata", + "statistic_id": "sensor.test", + "unit_of_measurement": new_unit, + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].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": new_unit} + ] diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index 9191851c777..bbca3a74139 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -1,6 +1,7 @@ """Tests for the Renault integration.""" from __future__ import annotations +from types import MappingProxyType from typing import Any from unittest.mock import patch @@ -10,6 +11,7 @@ from renault_api.renault_account import RenaultAccount from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + ATTR_ICON, ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, @@ -20,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceRegistry -from .const import MOCK_CONFIG, MOCK_VEHICLES +from .const import ICON_FOR_EMPTY_VALUES, MOCK_CONFIG, MOCK_VEHICLES from tests.common import MockConfigEntry, load_fixture @@ -61,9 +63,20 @@ def get_fixtures(vehicle_type: str) -> dict[str, Any]: if "hvac_status" in mock_vehicle["endpoints"] else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), + "location": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['location']}") + if "location" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleLocationDataSchema), } +def get_no_data_icon(expected_entity: MappingProxyType): + """Check attribute for icon for inactive sensors.""" + entity_id = expected_entity["entity_id"] + return ICON_FOR_EMPTY_VALUES.get(entity_id, expected_entity.get(ATTR_ICON)) + + async def setup_renault_integration_simple(hass: HomeAssistant): """Create the Renault integration.""" config_entry = get_mock_config_entry() @@ -124,6 +137,9 @@ async def setup_renault_integration_vehicle(hass: HomeAssistant, vehicle_type: s ), patch( "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", return_value=mock_fixtures["hvac_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location", + return_value=mock_fixtures["location"], ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -173,6 +189,9 @@ async def setup_renault_integration_vehicle_with_no_data( ), patch( "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", return_value=mock_fixtures["hvac_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location", + return_value=mock_fixtures["location"], ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -221,6 +240,9 @@ async def setup_renault_integration_vehicle_with_side_effect( ), patch( "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", side_effect=side_effect, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location", + side_effect=side_effect, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 2c742aa07cd..f9fb765dab3 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -4,6 +4,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PLUG, DOMAIN as BINARY_SENSOR_DOMAIN, ) +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, @@ -12,18 +13,32 @@ from homeassistant.components.renault.const import ( DEVICE_CLASS_PLUG_STATE, DOMAIN, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTIONS +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, CONF_PASSWORD, CONF_USERNAME, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, + STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_UNKNOWN, @@ -32,6 +47,23 @@ from homeassistant.const import ( VOLUME_LITERS, ) +FIXED_ATTRIBUTES = ( + ATTR_DEVICE_CLASS, + ATTR_OPTIONS, + ATTR_STATE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, +) +DYNAMIC_ATTRIBUTES = ( + ATTR_ICON, + ATTR_LAST_UPDATE, +) + +ICON_FOR_EMPTY_VALUES = { + "select.charge_mode": "mdi:calendar-remove", + "sensor.charge_state": "mdi:flash-off", + "sensor.plug_state": "mdi:power-plug-off", +} + # Mock config data to be used across multiple tests MOCK_CONFIG = { CONF_USERNAME: "email@test.com", @@ -52,6 +84,7 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit True, # hvac-status + False, # location True, # battery-status True, # charge-mode ], @@ -66,13 +99,26 @@ MOCK_VEHICLES = { "entity_id": "binary_sensor.plugged_in", "unique_id": "vf1aaaaa555777999_plugged_in", "result": STATE_ON, - "class": DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777999_charging", "result": STATE_ON, - "class": DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + }, + ], + DEVICE_TRACKER_DOMAIN: [], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "always", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], }, ], SENSOR_DOMAIN: [ @@ -80,72 +126,87 @@ MOCK_VEHICLES = { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777999_battery_autonomy", "result": "141", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:ev-station", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.battery_available_energy", "unique_id": "vf1aaaaa555777999_battery_available_energy", "result": "31", - "unit": ENERGY_KILO_WATT_HOUR, - "class": DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", "result": "60", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { "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, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", "result": "charge_in_progress", - "class": DEVICE_CLASS_CHARGE_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, + ATTR_ICON: "mdi:flash", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": "0.027", - "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, { "entity_id": "sensor.charging_remaining_time", "unique_id": "vf1aaaaa555777999_charging_remaining_time", "result": "145", - "unit": TIME_MINUTES, + ATTR_ICON: "mdi:timer", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777999_mileage", "result": "49114", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.outside_temperature", "unique_id": "vf1aaaaa555777999_outside_temperature", "result": "8.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.plug_state", "unique_id": "vf1aaaaa555777999_plug_state", "result": "plugged", - "class": DEVICE_CLASS_PLUG_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, + ATTR_ICON: "mdi:power-plug", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, ], }, @@ -160,6 +221,7 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit False, # hvac-status + True, # location True, # battery-status True, # charge-mode ], @@ -167,19 +229,41 @@ MOCK_VEHICLES = { "battery_status": "battery_status_not_charging.json", "charge_mode": "charge_mode_schedule.json", "cockpit": "cockpit_ev.json", + "location": "location.json", }, BINARY_SENSOR_DOMAIN: [ { "entity_id": "binary_sensor.plugged_in", "unique_id": "vf1aaaaa555777999_plugged_in", "result": STATE_OFF, - "class": DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777999_charging", "result": STATE_OFF, - "class": DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + }, + ], + DEVICE_TRACKER_DOMAIN: [ + { + "entity_id": "device_tracker.location", + "unique_id": "vf1aaaaa555777999_location", + "result": STATE_NOT_HOME, + ATTR_ICON: "mdi:car", + ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", + } + ], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "schedule_mode", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-clock", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], }, ], SENSOR_DOMAIN: [ @@ -187,65 +271,79 @@ MOCK_VEHICLES = { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777999_battery_autonomy", "result": "128", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:ev-station", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.battery_available_energy", "unique_id": "vf1aaaaa555777999_battery_available_energy", "result": "0", - "unit": ENERGY_KILO_WATT_HOUR, - "class": DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", "result": "50", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { "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, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", "result": "charge_error", - "class": DEVICE_CLASS_CHARGE_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, + ATTR_ICON: "mdi:flash-off", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, - "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, { "entity_id": "sensor.charging_remaining_time", "unique_id": "vf1aaaaa555777999_charging_remaining_time", "result": STATE_UNKNOWN, - "unit": TIME_MINUTES, + ATTR_ICON: "mdi:timer", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777999_mileage", "result": "49114", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.plug_state", "unique_id": "vf1aaaaa555777999_plug_state", "result": "unplugged", - "class": DEVICE_CLASS_PLUG_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, + ATTR_ICON: "mdi:power-plug-off", + ATTR_LAST_UPDATE: "2020-11-17T08:06:48+00:00", }, ], }, @@ -260,6 +358,7 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit False, # hvac-status + True, # location True, # battery-status True, # charge-mode ], @@ -267,19 +366,41 @@ MOCK_VEHICLES = { "battery_status": "battery_status_charging.json", "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_fuel.json", + "location": "location.json", }, BINARY_SENSOR_DOMAIN: [ { "entity_id": "binary_sensor.plugged_in", "unique_id": "vf1aaaaa555777123_plugged_in", "result": STATE_ON, - "class": DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "binary_sensor.charging", "unique_id": "vf1aaaaa555777123_charging", "result": STATE_ON, - "class": DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + }, + ], + DEVICE_TRACKER_DOMAIN: [ + { + "entity_id": "device_tracker.location", + "unique_id": "vf1aaaaa555777123_location", + "result": STATE_NOT_HOME, + ATTR_ICON: "mdi:car", + ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", + } + ], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777123_charge_mode", + "result": "always", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], }, ], SENSOR_DOMAIN: [ @@ -287,77 +408,95 @@ MOCK_VEHICLES = { "entity_id": "sensor.battery_autonomy", "unique_id": "vf1aaaaa555777123_battery_autonomy", "result": "141", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:ev-station", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.battery_available_energy", "unique_id": "vf1aaaaa555777123_battery_available_energy", "result": "31", - "unit": ENERGY_KILO_WATT_HOUR, - "class": DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777123_battery_level", "result": "60", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { "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, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777123_charge_state", "result": "charge_in_progress", - "class": DEVICE_CLASS_CHARGE_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_STATE, + ATTR_ICON: "mdi:flash", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, { "entity_id": "sensor.charging_power", "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", - "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, { "entity_id": "sensor.charging_remaining_time", "unique_id": "vf1aaaaa555777123_charging_remaining_time", "result": "145", - "unit": TIME_MINUTES, + ATTR_ICON: "mdi:timer", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, { "entity_id": "sensor.fuel_autonomy", "unique_id": "vf1aaaaa555777123_fuel_autonomy", "result": "35", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:gas-station", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.fuel_quantity", "unique_id": "vf1aaaaa555777123_fuel_quantity", "result": "3", - "unit": VOLUME_LITERS, + ATTR_ICON: "mdi:fuel", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LITERS, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777123_mileage", "result": "5567", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.plug_state", "unique_id": "vf1aaaaa555777123_plug_state", "result": "plugged", - "class": DEVICE_CLASS_PLUG_STATE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, + ATTR_ICON: "mdi:power-plug", + ATTR_LAST_UPDATE: "2020-01-12T21:40:16+00:00", }, ], }, @@ -372,29 +511,49 @@ MOCK_VEHICLES = { "endpoints_available": [ True, # cockpit False, # hvac-status + True, # location # Ignore, # battery-status # Ignore, # charge-mode ], - "endpoints": {"cockpit": "cockpit_fuel.json"}, + "endpoints": { + "cockpit": "cockpit_fuel.json", + "location": "location.json", + }, BINARY_SENSOR_DOMAIN: [], + DEVICE_TRACKER_DOMAIN: [ + { + "entity_id": "device_tracker.location", + "unique_id": "vf1aaaaa555777123_location", + "result": STATE_NOT_HOME, + ATTR_ICON: "mdi:car", + ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", + } + ], + SELECT_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", "unique_id": "vf1aaaaa555777123_fuel_autonomy", "result": "35", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:gas-station", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { "entity_id": "sensor.fuel_quantity", "unique_id": "vf1aaaaa555777123_fuel_quantity", "result": "3", - "unit": VOLUME_LITERS, + ATTR_ICON: "mdi:fuel", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LITERS, }, { "entity_id": "sensor.mileage", "unique_id": "vf1aaaaa555777123_mileage", "result": "5567", - "unit": LENGTH_KILOMETERS, + ATTR_ICON: "mdi:sign-direction", + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, ], }, diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 71bb90f16a6..a89c1da7808 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -5,22 +5,25 @@ import pytest from renault_api.kamereon import exceptions from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.const import ATTR_ICON, STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( check_device_registry, + get_no_data_icon, setup_renault_integration_vehicle, setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) -from .const import MOCK_VEHICLES +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES from tests.common import mock_device_registry, mock_registry @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_binary_sensors(hass, vehicle_type): +async def test_binary_sensors(hass: HomeAssistant, vehicle_type: str): """Test for Renault binary sensors.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -40,14 +43,14 @@ async def test_binary_sensors(hass, vehicle_type): 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"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_binary_sensor_empty(hass, vehicle_type): +async def test_binary_sensor_empty(hass: HomeAssistant, vehicle_type: str): """Test for Renault binary sensors with empty data from Renault.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -67,14 +70,17 @@ async def test_binary_sensor_empty(hass, vehicle_type): 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_OFF + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_binary_sensor_errors(hass, vehicle_type): +async def test_binary_sensor_errors(hass: HomeAssistant, vehicle_type: str): """Test for Renault binary sensors with temporary failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -101,10 +107,13 @@ async def test_binary_sensor_errors(hass, vehicle_type): 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 + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes async def test_binary_sensor_access_denied(hass): diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 684e17a0101..ebf458541f0 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from . import get_mock_config_entry +from .const import MOCK_CONFIG from tests.common import load_fixture @@ -207,3 +208,54 @@ async def test_config_flow_duplicate(hass: HomeAssistant): await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth(hass): + """Test the start of the config flow.""" + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ): + original_entry = get_mock_config_entry() + original_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": original_entry.entry_id, + "unique_id": original_entry.unique_id, + }, + data=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["description_placeholders"] == {CONF_USERNAME: "email@test.com"} + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException( + 403042, "invalid loginID or password" + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "any"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["description_placeholders"] == {CONF_USERNAME: "email@test.com"} + assert result2["errors"] == {"base": "invalid_credentials"} + + # Valid credentials + with patch("renault_api.renault_session.RenaultSession.login"): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "any"}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py new file mode 100644 index 00000000000..f6cac06380b --- /dev/null +++ b/tests/components/renault/test_device_tracker.py @@ -0,0 +1,164 @@ +"""Tests for Renault sensors.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + get_no_data_icon, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_device_trackers(hass: HomeAssistant, vehicle_type: str): + """Test for Renault device trackers.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[DEVICE_TRACKER_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"] + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_device_tracker_empty(hass: HomeAssistant, vehicle_type: str): + """Test for Renault device trackers with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[DEVICE_TRACKER_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"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_device_tracker_errors(hass: HomeAssistant, vehicle_type: str): + """Test for Renault device trackers 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", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[DEVICE_TRACKER_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"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +async def test_device_tracker_access_denied(hass: HomeAssistant): + """Test for Renault device trackers with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_device_tracker_not_supported(hass: HomeAssistant): + """Test for Renault device trackers with not supported failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 37a67151972..3446bb1f9fa 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -6,11 +6,12 @@ from renault_api.gigya.exceptions import InvalidCredentialsException from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant from . import get_mock_config_entry, setup_renault_integration_simple -async def test_setup_unload_entry(hass): +async def test_setup_unload_entry(hass: HomeAssistant): """Test entry setup and unload.""" with patch("homeassistant.components.renault.PLATFORMS", []): config_entry = await setup_renault_integration_simple(hass) @@ -26,7 +27,7 @@ async def test_setup_unload_entry(hass): assert config_entry.entry_id not in hass.data[DOMAIN] -async def test_setup_entry_bad_password(hass): +async def test_setup_entry_bad_password(hass: HomeAssistant): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = get_mock_config_entry() @@ -44,7 +45,7 @@ async def test_setup_entry_bad_password(hass): assert not hass.data.get(DOMAIN) -async def test_setup_entry_exception(hass): +async def test_setup_entry_exception(hass: HomeAssistant): """Test ConfigEntryNotReady when API raises an exception during entry setup.""" config_entry = get_mock_config_entry() config_entry.add_to_hass(hass) diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py new file mode 100644 index 00000000000..113db099447 --- /dev/null +++ b/tests/components/renault/test_select.py @@ -0,0 +1,194 @@ +"""Tests for Renault selects.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions, schemas + +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + get_no_data_icon, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES + +from tests.common import load_fixture, mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_selects(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[SELECT_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"] + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_select_empty(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[SELECT_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"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_select_errors(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects 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", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[SELECT_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"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +async def test_select_access_denied(hass: HomeAssistant): + """Test for Renault selects with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_select_not_supported(hass: HomeAssistant): + """Test for Renault selects with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_select_charge_mode(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_ENTITY_ID: "select.charge_mode", + ATTR_OPTION: "always", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_mode", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_mode.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + SELECT_DOMAIN, SERVICE_SELECT_OPTION, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == ("always",) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 41fceccb56c..370721bb0dd 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -4,23 +4,26 @@ from unittest.mock import patch import pytest from renault_api.kamereon import exceptions +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( check_device_registry, + get_no_data_icon, setup_renault_integration_vehicle, setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) -from .const import MOCK_VEHICLES +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, 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): +async def test_sensors(hass: HomeAssistant, vehicle_type: str): """Test for Renault sensors.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -40,14 +43,14 @@ async def test_sensors(hass, vehicle_type): 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"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_sensor_empty(hass, vehicle_type): +async def test_sensor_empty(hass: HomeAssistant, vehicle_type: str): """Test for Renault sensors with empty data from Renault.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -67,14 +70,17 @@ async def test_sensor_empty(hass, vehicle_type): 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_UNKNOWN + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) -async def test_sensor_errors(hass, vehicle_type): +async def test_sensor_errors(hass: HomeAssistant, vehicle_type: str): """Test for Renault sensors with temporary failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -101,13 +107,16 @@ async def test_sensor_errors(hass, vehicle_type): 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 + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes -async def test_sensor_access_denied(hass): +async def test_sensor_access_denied(hass: HomeAssistant): """Test for Renault sensors with access denied failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) @@ -131,7 +140,7 @@ async def test_sensor_access_denied(hass): assert len(entity_registry.entities) == 0 -async def test_sensor_not_supported(hass): +async def test_sensor_not_supported(hass: HomeAssistant): """Test for Renault sensors with access denied failure.""" await async_setup_component(hass, "persistent_notification", {}) entity_registry = mock_registry(hass) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py new file mode 100644 index 00000000000..37c3d71af61 --- /dev/null +++ b/tests/components/renault/test_services.py @@ -0,0 +1,269 @@ +"""Tests for Renault sensors.""" +from datetime import datetime +from unittest.mock import patch + +import pytest +from renault_api.kamereon import schemas +from renault_api.kamereon.models import ChargeSchedule + +from homeassistant.components.renault.const import DOMAIN +from homeassistant.components.renault.services import ( + ATTR_SCHEDULES, + ATTR_TEMPERATURE, + ATTR_VEHICLE, + ATTR_WHEN, + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_CHARGE_START, + SERVICES, +) +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_renault_integration_simple, setup_renault_integration_vehicle + +from tests.common import load_fixture +from tests.components.renault.const import MOCK_VEHICLES + + +def get_device_id(hass: HomeAssistant) -> str: + """Get device_id.""" + device_registry = dr.async_get(hass) + identifiers = {(DOMAIN, "VF1AAAAA555777999")} + device = device_registry.async_get_device(identifiers) + return device.id + + +async def test_service_registration(hass: HomeAssistant): + """Test entry setup and unload.""" + with patch("homeassistant.components.renault.PLATFORMS", []): + config_entry = await setup_renault_integration_simple(hass) + + # Check that all services are registered. + for service in SERVICES: + assert hass.services.has_service(DOMAIN, service) + + # Unload the entry + await hass.config_entries.async_unload(config_entry.entry_id) + + # Check that all services are un-registered. + for service in SERVICES: + assert not hass.services.has_service(DOMAIN, service) + + +async def test_service_set_ac_cancel(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_stop.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +async def test_service_set_ac_start_simple(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + temperature = 13.5 + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_TEMPERATURE: temperature, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (temperature, None) + + +async def test_service_set_ac_start_with_date(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + temperature = 13.5 + when = datetime(2025, 8, 23, 17, 12, 45) + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_TEMPERATURE: temperature, + ATTR_WHEN: when, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (temperature, when) + + +async def test_service_set_charge_schedule(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + schedules = {"id": 2} + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/charging_settings.json") + ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_schedules.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_action.mock_calls[0][1] == (mock_call_data,) + + +async def test_service_set_charge_schedule_multi(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + schedules = [ + { + "id": 2, + "activated": True, + "monday": {"startTime": "T12:00Z", "duration": 15}, + "tuesday": {"startTime": "T12:00Z", "duration": 15}, + "wednesday": {"startTime": "T12:00Z", "duration": 15}, + "thursday": {"startTime": "T12:00Z", "duration": 15}, + "friday": {"startTime": "T12:00Z", "duration": 15}, + "saturday": {"startTime": "T12:00Z", "duration": 15}, + "sunday": {"startTime": "T12:00Z", "duration": 15}, + }, + {"id": 3}, + ] + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/charging_settings.json") + ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_schedules.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_action.mock_calls[0][1] == (mock_call_data,) + + +async def test_service_set_charge_start(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +async def test_service_invalid_device_id(hass: HomeAssistant): + """Test that service fails with ValueError if device_id not found in registry.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = {ATTR_VEHICLE: "VF1AAAAA555777999"} + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + + +async def test_service_invalid_device_id2(hass: HomeAssistant): + """Test that service fails with ValueError if device_id not found in vehicles.""" + config_entry = await setup_renault_integration_vehicle(hass, "zoe_40") + + extra_vehicle = MOCK_VEHICLES["captur_phev"]["expected_device"] + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=extra_vehicle[ATTR_IDENTIFIERS], + manufacturer=extra_vehicle[ATTR_MANUFACTURER], + name=extra_vehicle[ATTR_NAME], + model=extra_vehicle[ATTR_MODEL], + sw_version=extra_vehicle[ATTR_SW_VERSION], + ) + device_id = device_registry.async_get_device(extra_vehicle[ATTR_IDENTIFIERS]).id + + data = {ATTR_VEHICLE: device_id} + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1c16507f960..07c316618e3 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the Tado config flow.""" +"""Test the Rfxtrx config flow.""" import os from unittest.mock import MagicMock, patch, sentinel @@ -32,11 +32,8 @@ def com_port(): return port -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", - return_value=None, -) -async def test_setup_network(connect_mock, hass): +@patch("homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport", autospec=True) +async def test_setup_network(transport_mock, hass): """Test we can setup network.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -170,10 +167,11 @@ async def test_setup_serial_manual(com_mock, connect_mock, hass): @patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", + "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport", + autospec=True, side_effect=OSError, ) -async def test_setup_network_fail(connect_mock, hass): +async def test_setup_network_fail(transport_mock, hass): """Test we can setup network.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -277,143 +275,6 @@ async def test_setup_serial_manual_fail(com_mock, hass): assert result["errors"] == {"base": "cannot_connect"} -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - serial_connect, -) -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.close", - return_value=None, -) -async def test_import_serial(connect_mock, hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": None, "port": None, "device": "/dev/tty123", "debug": False}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "RFXTRX" - assert result["data"] == { - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": False, - } - - -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", - return_value=None, -) -async def test_import_network(connect_mock, hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": "localhost", "port": 1234, "device": None, "debug": False}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "RFXTRX" - assert result["data"] == { - "host": "localhost", - "port": 1234, - "device": None, - "debug": False, - } - - -@patch( - "homeassistant.components.rfxtrx.rfxtrxmod.PyNetworkTransport.connect", - side_effect=OSError, -) -async def test_import_network_connection_fail(connect_mock, hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": "localhost", "port": 1234, "device": None, "debug": False}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - -async def test_import_update(hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": False, - "devices": {}, - }, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": True, - "devices": {}, - }, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - -async def test_import_migrate(hass): - """Test we can import.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - entry = MockConfigEntry( - domain=DOMAIN, - data={"host": None, "port": None, "device": "/dev/tty123", "debug": False}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch("homeassistant.components.rfxtrx.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "host": None, - "port": None, - "device": "/dev/tty123", - "debug": True, - "automatic_add": True, - "devices": {}, - }, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - assert entry.data["devices"] == {} - - async def test_options_global(hass): """Test if we can set global options.""" await setup.async_setup_component(hass, "persistent_notification", {}) diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py new file mode 100644 index 00000000000..cedf2082fb2 --- /dev/null +++ b/tests/components/rfxtrx/test_device_action.py @@ -0,0 +1,206 @@ +"""The tests for RFXCOM RFXtrx device actions.""" +from __future__ import annotations + +from typing import Any, NamedTuple + +import RFXtrx +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) +from tests.components.rfxtrx.conftest import create_rfx_test_cfg + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +class DeviceTestData(NamedTuple): + """Test data linked to a device.""" + + code: str + device_identifiers: set[tuple[str, str, str, str]] + + +DEVICE_LIGHTING_1 = DeviceTestData("0710002a45050170", {("rfxtrx", "10", "0", "E5")}) + +DEVICE_BLINDS_1 = DeviceTestData( + "09190000009ba8010100", {("rfxtrx", "19", "0", "009ba8:1")} +) + +DEVICE_TEMPHUM_1 = DeviceTestData( + "0a52080705020095220269", {("rfxtrx", "52", "8", "05:02")} +) + + +@pytest.mark.parametrize("device", [DEVICE_LIGHTING_1, DEVICE_TEMPHUM_1]) +async def test_device_test_data(rfxtrx, device: DeviceTestData): + """Verify that our testing data remains correct.""" + pkt: RFXtrx.lowlevel.Packet = RFXtrx.lowlevel.parse(bytearray.fromhex(device.code)) + assert device.device_identifiers == { + ("rfxtrx", f"{pkt.packettype:x}", f"{pkt.subtype:x}", pkt.id_string) + } + + +async def setup_entry(hass, devices): + """Construct a config setup.""" + entry_data = create_rfx_test_cfg(devices=devices) + 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() + await hass.async_start() + + +def _get_expected_actions(data): + for value in data.values(): + yield {"type": "send_command", "subtype": value} + + +@pytest.mark.parametrize( + "device,expected", + [ + [ + DEVICE_LIGHTING_1, + list(_get_expected_actions(RFXtrx.lowlevel.Lighting1.COMMANDS)), + ], + [ + DEVICE_BLINDS_1, + list(_get_expected_actions(RFXtrx.lowlevel.RollerTrol.COMMANDS)), + ], + [DEVICE_TEMPHUM_1, []], + ], +) +async def test_get_actions(hass, device_reg: DeviceRegistry, device, expected): + """Test we get the expected actions from a rfxtrx.""" + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(device.device_identifiers, set()) + assert device_entry + + actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = [action for action in actions if action["domain"] == DOMAIN] + + expected_actions = [ + {"domain": DOMAIN, "device_id": device_entry.id, **action_type} + for action_type in expected + ] + + assert_lists_same(actions, expected_actions) + + +@pytest.mark.parametrize( + "device,config,expected", + [ + [ + DEVICE_LIGHTING_1, + {"type": "send_command", "subtype": "On"}, + "0710000045050100", + ], + [ + DEVICE_LIGHTING_1, + {"type": "send_command", "subtype": "Off"}, + "0710000045050000", + ], + [ + DEVICE_BLINDS_1, + {"type": "send_command", "subtype": "Stop"}, + "09190000009ba8010200", + ], + ], +) +async def test_action( + hass, device_reg: DeviceRegistry, rfxtrx: RFXtrx.Connect, device, config, expected +): + """Test for actions.""" + + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(device.device_identifiers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + **config, + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + rfxtrx.transport.send.assert_called_once_with(bytearray.fromhex(expected)) + + +async def test_invalid_action(hass, device_reg: DeviceRegistry): + """Test for invalid actions.""" + device = DEVICE_LIGHTING_1 + notification_calls = async_mock_service(hass, "persistent_notification", "create") + + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_identifers: Any = device.device_identifiers + device_entry = device_reg.async_get_device(device_identifers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "send_command", + "subtype": "invalid", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(notification_calls) == 1 + assert ( + "The following integrations and platforms could not be set up" + in notification_calls[0].data["message"] + ) diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py new file mode 100644 index 00000000000..9ac2c7e9819 --- /dev/null +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -0,0 +1,186 @@ +"""The tests for RFXCOM RFXtrx device triggers.""" +from __future__ import annotations + +from typing import Any, NamedTuple + +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, +) +from tests.components.rfxtrx.conftest import create_rfx_test_cfg + + +class EventTestData(NamedTuple): + """Test data linked to a device.""" + + code: str + device_identifiers: set[tuple[str, str, str, str]] + type: str + subtype: str + + +DEVICE_LIGHTING_1 = {("rfxtrx", "10", "0", "E5")} +EVENT_LIGHTING_1 = EventTestData("0710002a45050170", DEVICE_LIGHTING_1, "command", "On") + +DEVICE_ROLLERTROL_1 = {("rfxtrx", "19", "0", "009ba8:1")} +EVENT_ROLLERTROL_1 = EventTestData( + "09190000009ba8010100", DEVICE_ROLLERTROL_1, "command", "Down" +) + +DEVICE_FIREALARM_1 = {("rfxtrx", "20", "3", "a10900:32")} +EVENT_FIREALARM_1 = EventTestData( + "08200300a109000670", DEVICE_FIREALARM_1, "status", "Panic" +) + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +async def setup_entry(hass, devices): + """Construct a config setup.""" + entry_data = create_rfx_test_cfg(devices=devices) + 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() + await hass.async_start() + + +@pytest.mark.parametrize( + "event,expected", + [ + [ + EVENT_LIGHTING_1, + [ + {"type": "command", "subtype": subtype} + for subtype in [ + "Off", + "On", + "Dim", + "Bright", + "All/group Off", + "All/group On", + "Chime", + "Illegal command", + ] + ], + ] + ], +) +async def test_get_triggers(hass, device_reg, event: EventTestData, expected): + """Test we get the expected triggers from a rfxtrx.""" + await setup_entry(hass, {event.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(event.device_identifiers, set()) + + expected_triggers = [ + {"domain": DOMAIN, "device_id": device_entry.id, "platform": "device", **expect} + for expect in expected + ] + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = [value for value in triggers if value["domain"] == "rfxtrx"] + assert_lists_same(triggers, expected_triggers) + + +@pytest.mark.parametrize( + "event", + [ + EVENT_LIGHTING_1, + EVENT_ROLLERTROL_1, + EVENT_FIREALARM_1, + ], +) +async def test_firing_event(hass, device_reg: DeviceRegistry, rfxtrx, event): + """Test for turn_on and turn_off triggers firing.""" + + await setup_entry(hass, {event.code: {"fire_event": True, "signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(event.device_identifiers, set()) + assert device_entry + + calls = async_mock_service(hass, "test", "automation") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": event.type, + "subtype": event.subtype, + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("{{trigger.platform}}")}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + await rfxtrx.signal(event.code) + + assert len(calls) == 1 + assert calls[0].data["some"] == "device" + + +async def test_invalid_trigger(hass, device_reg: DeviceRegistry): + """Test for invalid actions.""" + event = EVENT_LIGHTING_1 + notification_calls = async_mock_service(hass, "persistent_notification", "create") + + await setup_entry(hass, {event.code: {"fire_event": True, "signal_repetitions": 1}}) + + device_identifers: Any = event.device_identifiers + device_entry = device_reg.async_get_device(device_identifers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": event.type, + "subtype": "invalid", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("{{trigger.platform}}")}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(notification_calls) == 1 + assert ( + "The following integrations and platforms could not be set up" + in notification_calls[0].data["message"] + ) diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index 3625c23ebb8..0c904896090 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -6,58 +6,11 @@ from homeassistant.components.rfxtrx import DOMAIN from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.rfxtrx.conftest import create_rfx_test_cfg -async def test_valid_config(hass): - """Test configuration.""" - assert await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "/dev/serial/by-id/usb" - + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", - } - }, - ) - - -async def test_valid_config2(hass): - """Test configuration.""" - assert await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "/dev/serial/by-id/usb" - + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", - "debug": True, - } - }, - ) - - -async def test_invalid_config(hass): - """Test configuration.""" - assert not await async_setup_component(hass, "rfxtrx", {"rfxtrx": {}}) - - assert not await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "/dev/serial/by-id/usb" - + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", - "invalid_key": True, - } - }, - ) - - async def test_fire_event(hass, rfxtrx): """Test fire event.""" entry_data = create_rfx_test_cfg( diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py index 35555e2b842..1f12d3e651e 100644 --- a/tests/components/rituals_perfume_genie/common.py +++ b/tests/components/rituals_perfume_genie/common.py @@ -10,12 +10,12 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -def mock_config_entry(uniqe_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: +def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: """Return a mock Config Entry for the Rituals Perfume Genie integration.""" return MockConfigEntry( domain=DOMAIN, title="name@example.com", - unique_id=uniqe_id, + unique_id=unique_id, data={ACCOUNT_HASH: "an_account_hash"}, entry_id=entry_id, ) @@ -32,6 +32,8 @@ def mock_diffuser( is_on: bool = True, name: str = "Genie", perfume: str = "Ritual of Sakura", + perfume_amount: int = 2, + room_size_square_meter: int = 60, version: str = "4.0", wifi_percentage: int = 75, ) -> MagicMock: @@ -47,6 +49,10 @@ def mock_diffuser( diffuser_mock.is_on = is_on diffuser_mock.name = name diffuser_mock.perfume = perfume + diffuser_mock.perfume_amount = perfume_amount + diffuser_mock.room_size_square_meter = room_size_square_meter + diffuser_mock.set_perfume_amount = AsyncMock() + diffuser_mock.set_room_size_square_meter = AsyncMock() diffuser_mock.turn_off = AsyncMock() diffuser_mock.turn_on = AsyncMock() diffuser_mock.update_data = AsyncMock() @@ -55,12 +61,12 @@ def mock_diffuser( return diffuser_mock -def mock_diffuser_v1_battery_cartridge(): +def mock_diffuser_v1_battery_cartridge() -> MagicMock: """Create and return a mock version 1 Diffuser with battery and a cartridge.""" return mock_diffuser(hublot="lot123v1") -def mock_diffuser_v2_no_battery_no_cartridge(): +def mock_diffuser_v2_no_battery_no_cartridge() -> MagicMock: """Create and return a mock version 2 Diffuser without battery and cartridge.""" return mock_diffuser( hublot="lot123v2", diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py new file mode 100644 index 00000000000..f2e499655ca --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -0,0 +1,30 @@ +"""Tests for the Rituals Perfume Genie binary sensor platform.""" +from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING +from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, +) + + +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Test the creation and values of the Rituals Perfume Genie binary sensor.""" + config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + registry = entity_registry.async_get(hass) + hublot = diffuser.hublot + + state = hass.states.get("binary_sensor.genie_battery_charging") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_BATTERY_CHARGING + + entry = registry.async_get("binary_sensor.genie_battery_charging") + assert entry + assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 887417a41f8..ea79a99da0e 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -12,7 +12,7 @@ from .common import init_integration, mock_config_entry async def test_config_entry_not_ready(hass: HomeAssistant): """Test the Rituals configuration entry setup if connection to Rituals is missing.""" - config_entry = mock_config_entry(uniqe_id="id_123_not_ready") + config_entry = mock_config_entry(unique_id="id_123_not_ready") config_entry.add_to_hass(hass) with patch( "homeassistant.components.rituals_perfume_genie.Account.get_devices", @@ -24,7 +24,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant): async def test_config_entry_unload(hass: HomeAssistant) -> None: """Test the Rituals Perfume Genie configuration entry setup and unloading.""" - config_entry = mock_config_entry(uniqe_id="id_123_unload") + config_entry = mock_config_entry(unique_id="id_123_unload") await init_integration(hass, config_entry) await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py new file mode 100644 index 00000000000..fc3937897b9 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -0,0 +1,161 @@ +"""Tests for the Rituals Perfume Genie number platform.""" +from __future__ import annotations + +import pytest + +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_VALUE, + SERVICE_SET_VALUE, +) +from homeassistant.components.rituals_perfume_genie.number import ( + MAX_PERFUME_AMOUNT, + MIN_PERFUME_AMOUNT, + PERFUME_AMOUNT_SUFFIX, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser, + mock_diffuser_v1_battery_cartridge, +) + + +async def test_number_entity(hass: HomeAssistant) -> None: + """Test the creation and values of the diffuser number entity.""" + config_entry = mock_config_entry(unique_id="number_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) + await init_integration(hass, config_entry, [diffuser]) + + registry = entity_registry.async_get(hass) + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == str(diffuser.perfume_amount) + assert state.attributes[ATTR_ICON] == "mdi:gauge" + assert state.attributes[ATTR_MIN] == MIN_PERFUME_AMOUNT + assert state.attributes[ATTR_MAX] == MAX_PERFUME_AMOUNT + + entry = registry.async_get("number.genie_perfume_amount") + assert entry + assert entry.unique_id == f"{diffuser.hublot}{PERFUME_AMOUNT_SUFFIX}" + + +async def test_set_number_value(hass: HomeAssistant) -> None: + """Test setting the diffuser number entity value.""" + config_entry = mock_config_entry(unique_id="number_set_value_test") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + diffuser.perfume_amount = 1 + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 1}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "1" + + +async def test_set_number_value_out_of_range(hass: HomeAssistant): + """Test setting the diffuser number entity value out of range.""" + config_entry = mock_config_entry(unique_id="number_set_value_out_of_range_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 4}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 0}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "2" + + +async def test_set_number_value_to_float(hass: HomeAssistant): + """Test setting the diffuser number entity value to a float.""" + config_entry = mock_config_entry(unique_id="number_set_value_to_float_test") + diffuser = mock_diffuser(hublot="lot123", perfume_amount=3) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "3" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 1.5}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["number.genie_perfume_amount"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "3" diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py new file mode 100644 index 00000000000..fb159166fb7 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -0,0 +1,100 @@ +"""Tests for the Rituals Perfume Genie select platform.""" +import pytest + +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.rituals_perfume_genie.select import ROOM_SIZE_SUFFIX +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, ATTR_OPTIONS +from homeassistant.const import ( + AREA_SQUARE_METERS, + ATTR_ENTITY_ID, + ATTR_ICON, + SERVICE_SELECT_OPTION, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .common import init_integration, mock_config_entry, mock_diffuser + + +async def test_select_entity(hass: HomeAssistant) -> None: + """Test the creation and state of the diffuser select entity.""" + config_entry = mock_config_entry(unique_id="select_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) + await init_integration(hass, config_entry, [diffuser]) + + registry = entity_registry.async_get(hass) + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == str(diffuser.room_size_square_meter) + assert state.attributes[ATTR_ICON] == "mdi:ruler-square" + assert state.attributes[ATTR_OPTIONS] == ["15", "30", "60", "100"] + + entry = registry.async_get("select.genie_room_size") + assert entry + assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" + assert entry.unit_of_measurement == AREA_SQUARE_METERS + + +async def test_select_option(hass: HomeAssistant) -> None: + """Test selecting of a option.""" + config_entry = mock_config_entry(unique_id="select_invalid_option_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + diffuser.room_size_square_meter = 30 + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.genie_room_size", ATTR_OPTION: "30"}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["select.genie_room_size"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "30" + + +async def test_select_invalid_option(hass: HomeAssistant) -> None: + """Test selecting an invalid option.""" + config_entry = mock_config_entry(unique_id="select_invalid_option_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.genie_room_size", ATTR_OPTION: "120"}, + blocking=True, + ) + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["select.genie_room_size"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index 477353d3b83..2c72d429a99 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -26,7 +26,7 @@ from .common import ( async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> None: """Test the creation and values of the Rituals Perfume Genie sensors.""" - config_entry = mock_config_entry(uniqe_id="id_123_sensor_test_diffuser_v1") + config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v1") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) registry = entity_registry.async_get(hass) @@ -73,7 +73,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non async def test_sensors_diffuser_v2_no_battery_no_cartridge(hass: HomeAssistant) -> None: """Test the creation and values of the Rituals Perfume Genie sensors.""" - config_entry = mock_config_entry(uniqe_id="id_123_sensor_test_diffuser_v2") + config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v2") await init_integration( hass, config_entry, [mock_diffuser_v2_no_battery_no_cartridge()] diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index a2691da0e0e..960923a1b77 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -25,7 +25,7 @@ from .common import ( async def test_switch_entity(hass: HomeAssistant) -> None: """Test the creation and values of the Rituals Perfume Genie diffuser switch.""" - config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + config_entry = mock_config_entry(unique_id="id_123_switch_test") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) @@ -43,7 +43,7 @@ async def test_switch_entity(hass: HomeAssistant) -> None: async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: """Test handling a coordinator update.""" - config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + config_entry = mock_config_entry(unique_id="switch_handle_coordinator_update_test") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, "homeassistant", {}) @@ -74,7 +74,7 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: async def test_set_switch_state(hass: HomeAssistant) -> None: """Test changing the diffuser switch entity state.""" - config_entry = mock_config_entry(uniqe_id="id_123_switch_set_state_test") + config_entry = mock_config_entry(unique_id="id_123_switch_set_state_test") await init_integration(hass, config_entry, [mock_diffuser_v1_battery_cartridge()]) state = hass.states.get("switch.genie") diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index c3da2652a6d..05c51fdf591 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -112,3 +112,10 @@ def delay_fixture(): def mock_now(): """Fixture for dtutil.now.""" return dt_util.utcnow() + + +@pytest.fixture(name="no_mac_address") +def mac_address_fixture(): + """Patch getmac.get_mac_address.""" + with patch("getmac.get_mac_address", return_value=None) as mac: + yield mac diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 64d0c95c084..d9c96982aa1 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -389,7 +389,9 @@ async def test_ssdp_legacy_not_supported(hass: HomeAssistant, remote: Mock): async def test_ssdp_websocket_success_populates_mac_address( - hass: HomeAssistant, remotews: Mock + hass: HomeAssistant, + remote: Mock, + remotews: Mock, ): """Test starting a flow from ssdp for a supported device populates the mac.""" result = await hass.config_entries.flow.async_init( @@ -441,7 +443,9 @@ async def test_ssdp_model_not_supported(hass: HomeAssistant, remote: Mock): assert result["reason"] == RESULT_NOT_SUPPORTED -async def test_ssdp_not_successful(hass: HomeAssistant, remote: Mock): +async def test_ssdp_not_successful( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -469,7 +473,9 @@ async def test_ssdp_not_successful(hass: HomeAssistant, remote: Mock): assert result["reason"] == RESULT_CANNOT_CONNECT -async def test_ssdp_not_successful_2(hass: HomeAssistant, remote: Mock): +async def test_ssdp_not_successful_2( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -564,7 +570,9 @@ async def test_import_legacy(hass: HomeAssistant, remote: Mock): assert entries[0].data[CONF_PORT] == LEGACY_PORT -async def test_import_legacy_without_name(hass: HomeAssistant, remote: Mock): +async def test_import_legacy_without_name( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test importing from yaml without a name.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -651,7 +659,7 @@ async def test_import_unknown_host(hass: HomeAssistant, remotews: Mock): assert result["reason"] == RESULT_UNKNOWN_HOST -async def test_dhcp(hass: HomeAssistant, remotews: Mock): +async def test_dhcp(hass: HomeAssistant, remote: Mock, remotews: Mock): """Test starting a flow from dhcp.""" # confirm to add the entry result = await hass.config_entries.flow.async_init( @@ -677,7 +685,7 @@ async def test_dhcp(hass: HomeAssistant, remotews: Mock): assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -async def test_zeroconf(hass: HomeAssistant, remotews: Mock): +async def test_zeroconf(hass: HomeAssistant, remote: Mock, remotews: Mock): """Test starting a flow from zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -715,7 +723,7 @@ async def test_zeroconf_ignores_soundbar(hass: HomeAssistant, remotews_soundbar: async def test_zeroconf_no_device_info( - hass: HomeAssistant, remotews_no_device_info: Mock + hass: HomeAssistant, remote: Mock, remotews_no_device_info: Mock ): """Test starting a flow from zeroconf where device_info returns None.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index c5c1519556d..1f6c13809cb 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -53,7 +53,7 @@ REMOTE_CALL = { } -async def test_setup(hass: HomeAssistant, remote: Mock): +async def test_setup(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): """Test Samsung TV integration is setup.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -129,7 +129,9 @@ async def test_setup_duplicate_config(hass: HomeAssistant, remote: Mock, caplog) assert "duplicate host entries found" in caplog.text -async def test_setup_duplicate_entries(hass: HomeAssistant, remote: Mock, caplog): +async def test_setup_duplicate_entries( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock, caplog +): """Test duplicate setup of platform.""" await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index de9183915a2..1d81769ad8b 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -127,10 +127,9 @@ def delay_fixture(): yield delay -@pytest.fixture -def mock_now(): - """Fixture for dtutil.now.""" - return dt_util.utcnow() +@pytest.fixture(autouse=True) +def mock_no_mac_address(no_mac_address): + """Fake mac address in all mediaplayer tests.""" async def setup_samsungtv(hass, config): diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 7463cc6755a..7859d133c29 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -45,10 +45,11 @@ async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): assert ( "Entity sensor.test () " - "with state_class measurement has set last_reset. Setting last_reset is " - "deprecated and will be unsupported from Home Assistant Core 2021.11. Please " - "update your configuration if state_class is manually configured, otherwise " - "report it to the custom component author." + "with state_class measurement has set last_reset. Setting last_reset for " + "entities with state_class other than 'total' is deprecated and will be " + "removed from Home Assistant Core 2021.11. Please update your configuration if " + "state_class is manually configured, otherwise report it to the custom " + "component author." ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c886007bc1c..9ae4b467da5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2,31 +2,46 @@ # pylint: disable=protected-access,invalid-name from datetime import timedelta import math +from statistics import mean from unittest.mock import patch import pytest from pytest import approx +from homeassistant import loader from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + StatisticsMeta, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( get_metadata, list_statistic_ids, statistics_during_period, ) +from homeassistant.components.recorder.util import session_scope from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from tests.common import async_setup_component, init_recorder_component from tests.components.recorder.common import wait_recording_done +BATTERY_SENSOR_ATTRIBUTES = { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", +} ENERGY_SENSOR_ATTRIBUTES = { "device_class": "energy", "state_class": "measurement", "unit_of_measurement": "kWh", } +NONE_SENSOR_ATTRIBUTES = { + "state_class": "measurement", +} POWER_SENSOR_ATTRIBUTES = { "device_class": "power", "state_class": "measurement", @@ -49,6 +64,16 @@ GAS_SENSOR_ATTRIBUTES = { } +@pytest.fixture(autouse=True) +def set_time_zone(): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + dt_util.set_default_time_zone(dt_util.get_time_zone("America/Regina")) + yield + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ @@ -83,18 +108,77 @@ def test_compile_hourly_statistics( hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit", + [ + (None, "%", "%"), + ], +) +def test_compile_hourly_statistics_purged_state_changes( + hass_recorder, caplog, device_class, unit, native_unit +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + mean = min = max = float(hist["sensor.test1"][-1].state) + + # Purge all states from the database + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=four): + hass.services.call("recorder", "purge", {"keep_days": 0}) + hass.block_till_done() + wait_recording_done(hass) + hist = history.get_significant_states(hass, zero, four) + assert not hist + + recorder.do_adhoc_statistics(start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -143,7 +227,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -151,12 +235,13 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes {"statistic_id": "sensor.test6", "unit_of_measurement": "°C"}, {"statistic_id": "sensor.test7", "unit_of_measurement": "°C"}, ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -169,6 +254,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test6", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -181,6 +267,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test7", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(13.050847), "min": approx(-10.0), "max": approx(30.0), @@ -193,7 +280,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", ["measurement", "total"]) @pytest.mark.parametrize( "units,device_class,unit,display_unit,factor", [ @@ -215,7 +302,10 @@ def test_compile_hourly_sum_statistics_amount( hass_recorder, caplog, units, state_class, device_class, unit, display_unit, factor ): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() hass.config.units = units recorder = hass.data[DATA_INSTANCE] @@ -229,39 +319,41 @@ def test_compile_hourly_sum_statistics_amount( seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": display_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(factor * seq[2]), "sum": approx(factor * 10.0), }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -271,7 +363,8 @@ def test_compile_hourly_sum_statistics_amount( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -313,14 +406,117 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "unit_of_measurement": unit, "last_reset": None, } - seq = [10, 15, 15, 15, 20, 20, 20, 10] + seq = [10, 15, 15, 15, 20, 20, 20, 25] # Make sure the sequence has consecutive equal states assert seq[1] == seq[2] == seq[3] + # Make sure the first and last state differ + assert seq[0] != seq[-1] + states = {"sensor.test1": []} + + # Insert states for a 1st statistics period one = zero for i in range(len(seq)): - one = one + timedelta(minutes=1) + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + # Insert states for a 2nd statistics period + two = zero + timedelta(minutes=5) + for i in range(len(seq)): + two = two + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(two).isoformat() + _states = record_meter_state( + hass, two, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + two + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(start=zero) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=5)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(minutes=5) + ), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=10)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(two)), + "state": approx(factor * seq[7]), + "sum": approx(factor * (2 * sum(seq) - seq[0])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_amount_invalid_last_reset( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, 15, 15, 15, 20, 20, 20, 25] + + states = {"sensor.test1": []} + + # Insert states + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + if i == 3: + attributes["last_reset"] = "festivus" # not a valid time _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -334,28 +530,30 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(one), + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), "state": approx(factor * seq[7]), - "sum": approx(factor * (sum(seq) - seq[0])), + "sum": approx(factor * (sum(seq) - seq[0] - seq[3])), }, ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Ignoring invalid last reset 'festivus' for sensor.test1" in caplog.text @pytest.mark.parametrize("state_class", ["measurement"]) @@ -384,7 +582,9 @@ def test_compile_hourly_sum_statistics_nan_inf_state( states = {"sensor.test1": []} one = zero for i in range(len(seq)): - one = one + timedelta(minutes=1) + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -398,18 +598,19 @@ def test_compile_hourly_sum_statistics_nan_inf_state( ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "max": None, "mean": None, "min": None, @@ -422,6 +623,202 @@ def test_compile_hourly_sum_statistics_nan_inf_state( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "entity_id,warning_1,warning_2", + [ + ( + "sensor.test1", + "", + "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue", + ), + ( + "sensor.today_energy", + "from integration demo ", + "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22", + ), + ( + "sensor.custom_sensor", + "from integration test ", + "report it to the custom component author", + ), + ], +) +@pytest.mark.parametrize("state_class", ["total_increasing"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_negative_state( + hass_recorder, + caplog, + entity_id, + warning_1, + warning_2, + state_class, + device_class, + unit, + native_unit, + factor, +): + """Test compiling hourly statistics with negative states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + hass.data.pop(loader.DATA_CUSTOM_COMPONENTS) + recorder = hass.data[DATA_INSTANCE] + + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + mocksensor = platform.MockSensor(name="custom_sensor") + mocksensor._attr_should_poll = False + platform.ENTITIES["custom_sensor"] = mocksensor + + setup_component( + hass, "sensor", {"sensor": [{"platform": "demo"}, {"platform": "test"}]} + ) + hass.block_till_done() + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + } + seq = [15, 16, 15, 16, 20, -20, 20, 10] + + states = {entity_id: []} + if state := hass.states.get(entity_id): + states[entity_id].append(state) + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + _states = record_meter_state(hass, one, entity_id, attributes, seq[i : i + 1]) + states[entity_id].extend(_states[entity_id]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)[entity_id] == dict(hist)[entity_id] + + recorder.do_adhoc_statistics(start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert { + "statistic_id": entity_id, + "unit_of_measurement": native_unit, + } in statistic_ids + stats = statistics_during_period(hass, zero, period="5minute") + assert stats[entity_id] == [ + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[7]), + "sum": approx(factor * 15), # (15 - 10) + (10 - 0) + }, + ] + assert "Error while processing event StatisticsTask" not in caplog.text + assert ( + f"Entity {entity_id} {warning_1}has state class total_increasing, but its state is negative" + in caplog.text + ) + assert warning_2 in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "EUR", "EUR", 1), + ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_total_no_reset( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, period0, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, period0 - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(start=period0) + wait_recording_done(hass) + recorder.do_adhoc_statistics(start=period1) + wait_recording_done(hass) + recorder.do_adhoc_statistics(start=period2) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, period0, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 30.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 60.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -435,7 +832,10 @@ def test_compile_hourly_sum_statistics_total_increasing( hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -447,29 +847,30 @@ def test_compile_hourly_sum_statistics_total_increasing( seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, @@ -479,7 +880,8 @@ def test_compile_hourly_sum_statistics_total_increasing( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -489,7 +891,8 @@ def test_compile_hourly_sum_statistics_total_increasing( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -513,7 +916,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( hass_recorder, caplog, device_class, unit, native_unit, factor ): """Test small dips in sensor readings do not trigger a reset.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -525,42 +931,41 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", attributes, seq + hass, period0, "sensor.test1", attributes, seq ) hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) not in caplog.text - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "last_reset": None, "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, @@ -570,7 +975,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( { "last_reset": None, "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -580,7 +986,8 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( { "last_reset": None, "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -594,7 +1001,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): """Test compiling hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -617,46 +1027,48 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", sns1_attr, seq1 + hass, period0, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, period0, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "kWh"} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(20.0), "sum": approx(10.0), }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -666,7 +1078,8 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -681,7 +1094,10 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): """Test compiling multiple hourly statistics.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period2 = period0 + timedelta(minutes=10) + period2_end = period0 + timedelta(minutes=15) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -699,24 +1115,24 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] four, eight, states = record_meter_states( - hass, zero, "sensor.test1", sns1_attr, seq1 + hass, period0, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, period0, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( - hass, zero - timedelta.resolution, eight + timedelta.resolution + hass, period0 - timedelta.resolution, eight + timedelta.resolution ) assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -724,22 +1140,24 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): {"statistic_id": "sensor.test2", "unit_of_measurement": "kWh"}, {"statistic_id": "sensor.test3", "unit_of_measurement": "kWh"}, ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(20.0), "sum": approx(10.0), }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -749,7 +1167,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -761,17 +1180,19 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "sensor.test2": [ { "statistic_id": "sensor.test2", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(130.0), "sum": approx(20.0), }, { "statistic_id": "sensor.test2", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -781,7 +1202,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test2", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -793,17 +1215,19 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "sensor.test3": [ { "statistic_id": "sensor.test3", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(zero), + "last_reset": process_timestamp_to_utc_isoformat(period0), "state": approx(5.0 / 1000), "sum": approx(5.0 / 1000), }, { "statistic_id": "sensor.test3", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "max": None, "mean": None, "min": None, @@ -813,7 +1237,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): }, { "statistic_id": "sensor.test3", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "start": process_timestamp_to_utc_isoformat(period2), + "end": process_timestamp_to_utc_isoformat(period2_end), "max": None, "mean": None, "min": None, @@ -859,14 +1284,15 @@ def test_compile_hourly_statistics_unchanged( hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=four) + recorder.do_adhoc_statistics(start=four) wait_recording_done(hass) - stats = statistics_during_period(hass, four) + stats = statistics_during_period(hass, four, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), + "end": process_timestamp_to_utc_isoformat(four + timedelta(minutes=5)), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -891,14 +1317,15 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(21.1864406779661), "min": approx(10.0), "max": approx(25.0), @@ -948,14 +1375,15 @@ def test_compile_hourly_statistics_unavailable( hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=four) + recorder.do_adhoc_statistics(start=four) wait_recording_done(hass) - stats = statistics_during_period(hass, four) + stats = statistics_during_period(hass, four, period="5minute") assert stats == { "sensor.test2": [ { "statistic_id": "sensor.test2", "start": process_timestamp_to_utc_isoformat(four), + "end": process_timestamp_to_utc_isoformat(four + timedelta(minutes=5)), "mean": approx(value), "min": approx(value), "max": approx(value), @@ -978,7 +1406,7 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): "homeassistant.components.sensor.recorder.compile_statistics", side_effect=Exception, ): - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert "Error while processing event StatisticsTask" in caplog.text @@ -1083,29 +1511,30 @@ def test_compile_hourly_statistics_changing_units_1( four, states = record_states(hass, zero, "sensor.test1", attributes) attributes["unit_of_measurement"] = "cats" four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes + hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] four, _states = record_states( - hass, zero + timedelta(hours=2), "sensor.test1", attributes + hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert "does not match the unit of already compiled" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1116,7 +1545,7 @@ def test_compile_hourly_statistics_changing_units_1( ] } - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=10)) wait_recording_done(hass) assert ( "The unit of sensor.test1 (cats) does not match the unit of already compiled " @@ -1126,12 +1555,13 @@ def test_compile_hourly_statistics_changing_units_1( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1169,13 +1599,13 @@ def test_compile_hourly_statistics_changing_units_2( four, states = record_states(hass, zero, "sensor.test1", attributes) attributes["unit_of_measurement"] = "cats" four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes + hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(minutes=30)) + recorder.do_adhoc_statistics(start=zero + timedelta(seconds=30 * 5)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text assert "and matches the unit of already compiled statistics" not in caplog.text @@ -1183,7 +1613,7 @@ def test_compile_hourly_statistics_changing_units_2( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == {} assert "Error while processing event StatisticsTask" not in caplog.text @@ -1213,30 +1643,31 @@ def test_compile_hourly_statistics_changing_units_3( } four, states = record_states(hass, zero, "sensor.test1", attributes) four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes + hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] attributes["unit_of_measurement"] = "cats" four, _states = record_states( - hass, zero + timedelta(hours=2), "sensor.test1", attributes + hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(start=zero) wait_recording_done(hass) assert "does not match the unit of already compiled" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1247,7 +1678,7 @@ def test_compile_hourly_statistics_changing_units_3( ] } - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=10)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text assert f"matches the unit of already compiled statistics ({unit})" in caplog.text @@ -1255,12 +1686,13 @@ def test_compile_hourly_statistics_changing_units_3( assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} ] - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, zero, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1283,7 +1715,9 @@ def test_compile_hourly_statistics_changing_statistics( hass_recorder, caplog, device_class, unit, native_unit, mean, min, max ): """Test compiling hourly statistics where units change during an hour.""" - zero = dt_util.utcnow() + period0 = dt_util.utcnow() + period0_end = period1 = period0 + timedelta(minutes=5) + period1_end = period0 + timedelta(minutes=10) hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) @@ -1297,48 +1731,57 @@ def test_compile_hourly_statistics_changing_statistics( "state_class": "total_increasing", "unit_of_measurement": unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes_1) - recorder.do_adhoc_statistics(period="hourly", start=zero) + four, states = record_states(hass, period0, "sensor.test1", attributes_1) + recorder.do_adhoc_statistics(start=period0) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": None} ] - metadata = get_metadata(hass, "sensor.test1") + metadata = get_metadata(hass, ("sensor.test1",)) assert metadata == { - "has_mean": True, - "has_sum": False, - "statistic_id": "sensor.test1", - "unit_of_measurement": None, + "sensor.test1": ( + 1, + { + "has_mean": True, + "has_sum": False, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + }, + ) } # Add more states, with changed state class - four, _states = record_states( - hass, zero + timedelta(hours=1), "sensor.test1", attributes_2 - ) + four, _states = record_states(hass, period1, "sensor.test1", attributes_2) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, period0, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + recorder.do_adhoc_statistics(start=period1) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": None} ] - metadata = get_metadata(hass, "sensor.test1") + metadata = get_metadata(hass, ("sensor.test1",)) assert metadata == { - "has_mean": False, - "has_sum": True, - "statistic_id": "sensor.test1", - "unit_of_measurement": None, + "sensor.test1": ( + 1, + { + "has_mean": False, + "has_sum": True, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + }, + ) } - stats = statistics_during_period(hass, zero) + stats = statistics_during_period(hass, period0, period="5minute") assert stats == { "sensor.test1": [ { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), + "start": process_timestamp_to_utc_isoformat(period0), + "end": process_timestamp_to_utc_isoformat(period0_end), "mean": approx(mean), "min": approx(min), "max": approx(max), @@ -1348,7 +1791,8 @@ def test_compile_hourly_statistics_changing_statistics( }, { "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "start": process_timestamp_to_utc_isoformat(period1), + "end": process_timestamp_to_utc_isoformat(period1_end), "mean": None, "min": None, "max": None, @@ -1362,12 +1806,262 @@ def test_compile_hourly_statistics_changing_statistics( assert "Error while processing event StatisticsTask" not in caplog.text -def record_states(hass, zero, entity_id, attributes): +def test_compile_statistics_hourly_summary(hass_recorder, caplog): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + zero = zero.replace(minute=0, second=0, microsecond=0) + # Travel to the future, recorder gets confused otherwise because states are added + # before the start of the recorder_run + zero += timedelta(hours=1) + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": None, + "state_class": "measurement", + "unit_of_measurement": "%", + } + + sum_attributes = { + "device_class": None, + "state_class": "total", + "unit_of_measurement": "EUR", + } + + def _weighted_average(seq, i, last_state): + total = 0 + duration = 0 + durations = [50, 200, 45] + if i > 0: + total += last_state * 5 + duration += 5 + for j, dur in enumerate(durations): + total += seq[j] * dur + duration += dur + return total / duration + + def _min(seq, last_state): + if last_state is None: + return min(seq) + return min([*seq, last_state]) + + def _max(seq, last_state): + if last_state is None: + return max(seq) + return max([*seq, last_state]) + + def _sum(seq, last_state, last_sum): + if last_state is None: + return seq[-1] - seq[0] + return last_sum[-1] + seq[-1] - last_state + + # Generate states for two hours + states = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + } + expected_minima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_maxima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_averages = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_states = {"sensor.test4": []} + expected_sums = {"sensor.test4": []} + last_states = { + "sensor.test1": None, + "sensor.test2": None, + "sensor.test3": None, + "sensor.test4": None, + } + start = zero + for i in range(24): + seq = [-10, 15, 30] + # test1 has same value in every period + four, _states = record_states(hass, start, "sensor.test1", attributes, seq) + states["sensor.test1"] += _states["sensor.test1"] + last_state = last_states["sensor.test1"] + expected_minima["sensor.test1"].append(_min(seq, last_state)) + expected_maxima["sensor.test1"].append(_max(seq, last_state)) + expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test1"] = seq[-1] + # test2 values change: min/max at the last state + seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] + four, _states = record_states(hass, start, "sensor.test2", attributes, seq) + states["sensor.test2"] += _states["sensor.test2"] + last_state = last_states["sensor.test2"] + expected_minima["sensor.test2"].append(_min(seq, last_state)) + expected_maxima["sensor.test2"].append(_max(seq, last_state)) + expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test2"] = seq[-1] + # test3 values change: min/max at the first state + seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] + four, _states = record_states(hass, start, "sensor.test3", attributes, seq) + states["sensor.test3"] += _states["sensor.test3"] + last_state = last_states["sensor.test3"] + expected_minima["sensor.test3"].append(_min(seq, last_state)) + expected_maxima["sensor.test3"].append(_max(seq, last_state)) + expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test3"] = seq[-1] + # test4 values grow + seq = [i, i + 0.5, i + 0.75] + start_meter = start + for j in range(len(seq)): + _states = record_meter_state( + hass, + start_meter, + "sensor.test4", + sum_attributes, + seq[j : j + 1], + ) + start_meter = start + timedelta(minutes=1) + states["sensor.test4"] += _states["sensor.test4"] + last_state = last_states["sensor.test4"] + expected_states["sensor.test4"].append(seq[-1]) + expected_sums["sensor.test4"].append( + _sum(seq, last_state, expected_sums["sensor.test4"]) + ) + last_states["sensor.test4"] = seq[-1] + + start += timedelta(minutes=5) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, four, significant_changes_only=False + ) + assert dict(states) == dict(hist) + wait_recording_done(hass) + + # Generate 5-minute statistics for two hours + start = zero + for i in range(24): + recorder.do_adhoc_statistics(start=start) + wait_recording_done(hass) + start += timedelta(minutes=5) + + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "%"}, + {"statistic_id": "sensor.test2", "unit_of_measurement": "%"}, + {"statistic_id": "sensor.test3", "unit_of_measurement": "%"}, + {"statistic_id": "sensor.test4", "unit_of_measurement": "EUR"}, + ] + + stats = statistics_during_period(hass, zero, period="5minute") + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + } + start = zero + end = zero + timedelta(minutes=5) + for i in range(24): + for entity_id in [ + "sensor.test1", + "sensor.test2", + "sensor.test3", + "sensor.test4", + ]: + expected_average = ( + expected_averages[entity_id][i] + if entity_id in expected_averages + else None + ) + expected_minimum = ( + expected_minima[entity_id][i] if entity_id in expected_minima else None + ) + expected_maximum = ( + expected_maxima[entity_id][i] if entity_id in expected_maxima else None + ) + expected_state = ( + expected_states[entity_id][i] if entity_id in expected_states else None + ) + expected_sum = ( + expected_sums[entity_id][i] if entity_id in expected_sums else None + ) + expected_stats[entity_id].append( + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(start), + "end": process_timestamp_to_utc_isoformat(end), + "mean": approx(expected_average), + "min": approx(expected_minimum), + "max": approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + } + ) + start += timedelta(minutes=5) + end += timedelta(minutes=5) + assert stats == expected_stats + + stats = statistics_during_period(hass, zero, period="hour") + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + } + start = zero + end = zero + timedelta(hours=1) + for i in range(2): + for entity_id in [ + "sensor.test1", + "sensor.test2", + "sensor.test3", + "sensor.test4", + ]: + expected_average = ( + mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_averages + else None + ) + expected_minimum = ( + min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_minima + else None + ) + expected_maximum = ( + max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_maxima + else None + ) + expected_state = ( + expected_states[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_states + else None + ) + expected_sum = ( + expected_sums[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_sums + else None + ) + expected_stats[entity_id].append( + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(start), + "end": process_timestamp_to_utc_isoformat(end), + "mean": approx(expected_average), + "min": approx(expected_minimum), + "max": approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + } + ) + start += timedelta(hours=1) + end += timedelta(hours=1) + assert stats == expected_stats + assert "Error while processing event StatisticsTask" not in caplog.text + + +def record_states(hass, zero, entity_id, attributes, seq=None): """Record some test states. We inject a bunch of state updates for measurement sensors. """ attributes = dict(attributes) + if seq is None: + seq = [-10, 15, 30] def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -1375,24 +2069,452 @@ def record_states(hass, zero, entity_id, attributes): wait_recording_done(hass) return hass.states.get(entity_id) - one = zero + timedelta(minutes=1) - two = one + timedelta(minutes=10) - three = two + timedelta(minutes=40) - four = three + timedelta(minutes=10) + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[entity_id].append(set_state(entity_id, "-10", attributes=attributes)) + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): - states[entity_id].append(set_state(entity_id, "15", attributes=attributes)) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=three): - states[entity_id].append(set_state(entity_id, "30", attributes=attributes)) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) return four, states +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), + (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), + (IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), + (METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), + ], +) +async def test_validate_statistics_supported_device_class( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # No statistics, invalid state - expect error + hass.states.async_set( + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "state_unit": "dogs", + "statistic_id": "sensor.test", + }, + "type": "unsupported_unit_state", + } + ], + } + await assert_validation_result(client, expected) + + # Statistics has run, invalid state - expect error + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, expected) + + # Valid state - empty response + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Valid state, statistic runs again - empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Remove the state - empty response + hass.states.async_remove("sensor.test") + await assert_validation_result(client, {}) + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_validate_statistics_supported_device_class_2( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + initial_attributes = {"state_class": "measurement"} + hass.states.async_set("sensor.test", 10, attributes=initial_attributes) + await hass.async_block_till_done() + await assert_validation_result(client, {}) + + # Statistics has run, device class set - expect error + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test", 12, attributes=attributes) + await hass.async_block_till_done() + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "metadata_unit": None, + "statistic_id": "sensor.test", + "supported_unit": unit, + }, + "type": "unsupported_unit_metadata", + } + ], + } + await assert_validation_result(client, expected) + + # Invalid state too, expect double errors + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "device_class": attributes["device_class"], + "metadata_unit": None, + "statistic_id": "sensor.test", + "supported_unit": unit, + }, + "type": "unsupported_unit_metadata", + }, + { + "data": { + "device_class": attributes["device_class"], + "state_unit": "dogs", + "statistic_id": "sensor.test", + }, + "type": "unsupported_unit_state", + }, + ], + } + await assert_validation_result(client, expected) + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_validate_statistics_unsupported_state_class( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await hass.async_block_till_done() + await assert_validation_result(client, {}) + + # Statistics has run, empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # State update with invalid state class, expect error + _attributes = dict(attributes) + _attributes.pop("state_class") + hass.states.async_set("sensor.test", 12, attributes=_attributes) + await hass.async_block_till_done() + expected = { + "sensor.test": [ + { + "data": { + "state_class": None, + "statistic_id": "sensor.test", + }, + "type": "unsupported_state_class", + } + ], + } + await assert_validation_result(client, expected) + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_validate_statistics_sensor_not_recorded( + hass, hass_ws_client, units, attributes, unit +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, valid state - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await hass.async_block_till_done() + await assert_validation_result(client, {}) + + # Statistics has run, empty response + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Sensor no longer recorded, expect error + expected = { + "sensor.test": [ + { + "data": {"statistic_id": "sensor.test"}, + "type": "entity_not_recorded", + } + ], + } + with patch( + "homeassistant.components.sensor.recorder.is_entity_recorded", + return_value=False, + ): + await assert_validation_result(client, expected) + + +@pytest.mark.parametrize( + "attributes", + [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], +) +async def test_validate_statistics_unsupported_device_class( + hass, hass_ws_client, attributes +): + """Test validate_statistics.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "sensor", {}) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + client = await hass_ws_client() + rec = hass.data[DATA_INSTANCE] + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set("sensor.test", 10, attributes=attributes) + await assert_validation_result(client, {}) + + # No statistics, changed unit - empty response + hass.states.async_set( + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await assert_validation_result(client, {}) + + # Run statistics, no statistics will be generated because of conflicting units + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + rec.do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_statistic_ids([]) + + # No statistics, changed unit - empty response + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await assert_validation_result(client, {}) + + # Run statistics one hour later, only the "dogs" state will be considered + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + rec.do_adhoc_statistics(start=now + timedelta(hours=1)) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}] + ) + await assert_validation_result(client, {}) + + # Change back to original unit - expect error + hass.states.async_set("sensor.test", 13, attributes=attributes) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + expected = { + "sensor.test": [ + { + "data": { + "metadata_unit": "dogs", + "state_unit": attributes.get("unit_of_measurement"), + "statistic_id": "sensor.test", + }, + "type": "units_changed", + } + ], + } + await assert_validation_result(client, expected) + + # Changed unit - empty response + hass.states.async_set( + "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + ) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Valid state, statistic runs again - empty response + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done) + await assert_validation_result(client, {}) + + # Remove the state - empty response + hass.states.async_remove("sensor.test") + await assert_validation_result(client, {}) + + def record_meter_states(hass, zero, entity_id, _attributes, seq): """Record some test states. @@ -1405,14 +2527,14 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): wait_recording_done(hass) return hass.states.get(entity_id) - one = zero + timedelta(minutes=15) - two = one + timedelta(minutes=30) - three = two + timedelta(minutes=15) - four = three + timedelta(minutes=15) - five = four + timedelta(minutes=30) - six = five + timedelta(minutes=15) - seven = six + timedelta(minutes=15) - eight = seven + timedelta(minutes=30) + one = zero + timedelta(seconds=15 * 5) # 00:01:15 + two = one + timedelta(seconds=30 * 5) # 00:03:45 + three = two + timedelta(seconds=15 * 5) # 00:05:00 + four = three + timedelta(seconds=15 * 5) # 00:06:15 + five = four + timedelta(seconds=30 * 5) # 00:08:45 + six = five + timedelta(seconds=15 * 5) # 00:10:00 + seven = six + timedelta(seconds=15 * 5) # 00:11:45 + eight = seven + timedelta(seconds=30 * 5) # 00:13:45 attributes = dict(_attributes) if "last_reset" in _attributes: @@ -1453,7 +2575,7 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): return four, eight, states -def record_meter_state(hass, zero, entity_id, _attributes, seq): +def record_meter_state(hass, zero, entity_id, attributes, seq): """Record test state. We inject a state update for meter sensor. @@ -1465,9 +2587,6 @@ def record_meter_state(hass, zero, entity_id, _attributes, seq): wait_recording_done(hass) return hass.states.get(entity_id) - attributes = dict(_attributes) - attributes["last_reset"] = zero.isoformat() - states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) @@ -1487,10 +2606,10 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes): wait_recording_done(hass) return hass.states.get(entity_id) - one = zero + timedelta(minutes=1) - two = one + timedelta(minutes=15) - three = two + timedelta(minutes=30) - four = three + timedelta(minutes=15) + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=15 * 5) + three = two + timedelta(seconds=30 * 5) + four = three + timedelta(seconds=15 * 5) states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 71157124806..9dbba7732ac 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -3,12 +3,13 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.shelly import ShellyDeviceWrapper +from homeassistant.components.shelly import BlockDeviceWrapper, RpcDeviceWrapper from homeassistant.components.shelly.const import ( - COAP, + BLOCK, DATA_CONFIG_ENTRY, DOMAIN, EVENT_SHELLY_CLICK, + RPC, ) from homeassistant.setup import async_setup_component @@ -54,6 +55,14 @@ MOCK_BLOCKS = [ ), ] +MOCK_CONFIG = { + "input:0": {"id": 0, "type": "button"}, + "switch:0": {"name": "test switch_0"}, + "sys": {"ui_data": {}}, + "wifi": { + "ap": {"ssid": "Test name"}, + }, +} MOCK_SHELLY = { "mac": "test-mac", @@ -62,6 +71,10 @@ MOCK_SHELLY = { "num_outputs": 2, } +MOCK_STATUS = { + "switch:0": {"output": True}, +} + @pytest.fixture(autouse=True) def mock_coap(): @@ -104,6 +117,7 @@ async def coap_wrapper(hass): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY, + firmware_version="some fw string", update=AsyncMock(), initialized=True, ) @@ -111,9 +125,44 @@ async def coap_wrapper(hass): hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, config_entry, device) + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) - await wrapper.async_setup() + wrapper.async_setup() + + return wrapper + + +@pytest.fixture +async def rpc_wrapper(hass): + """Setups a coap wrapper with mocked device.""" + await async_setup_component(hass, "shelly", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"sleep_period": 0, "model": "SNSW-001P16EU", "gen": 2}, + unique_id="12345678", + ) + config_entry.add_to_hass(hass) + + device = Mock( + call_rpc=AsyncMock(), + config=MOCK_CONFIG, + event={}, + shelly=MOCK_SHELLY, + status=MOCK_STATUS, + firmware_version="some fw string", + update=AsyncMock(), + initialized=True, + shutdown=AsyncMock(), + ) + + hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} + hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ + RPC + ] = RpcDeviceWrapper(hass, config_entry, device) + + wrapper.async_setup() return wrapper diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 463c9111a60..1cc102715c5 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -20,9 +20,13 @@ DISCOVERY_INFO = { "name": "shelly1pm-12345", "properties": {"id": "shelly1pm-12345"}, } +MOCK_CONFIG = { + "wifi": {"ap": {"ssid": "Test name"}}, +} -async def test_form(hass): +@pytest.mark.parametrize("gen", [1, 2]) +async def test_form(hass, gen): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -32,15 +36,25 @@ async def test_form(hass): assert result["errors"] == {} with patch( - "aioshelly.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + "aioshelly.common.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": gen}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), + ), patch( + "aioshelly.rpc_device.RpcDevice.create", + new=AsyncMock( + return_value=Mock( + model="SHSW-1", + config=MOCK_CONFIG, + shutdown=AsyncMock(), + ) + ), ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -59,6 +73,7 @@ async def test_form(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -78,12 +93,13 @@ async def test_title_without_name(hass): settings["device"] = settings["device"].copy() settings["device"]["hostname"] = "shelly1pm-12345" with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=settings, ) ), @@ -105,6 +121,7 @@ async def test_title_without_name(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -119,7 +136,7 @@ async def test_form_auth(hass): assert result["errors"] == {} with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, ): result2 = await hass.config_entries.flow.async_configure( @@ -131,9 +148,10 @@ async def test_form_auth(hass): assert result["errors"] == {} with patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), @@ -155,6 +173,7 @@ async def test_form_auth(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, "username": "test username", "password": "test password", } @@ -172,7 +191,7 @@ async def test_form_errors_get_info(hass, error): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioshelly.get_info", side_effect=exc): + with patch("aioshelly.common.get_info", side_effect=exc): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -193,8 +212,10 @@ async def test_form_errors_test_connection(hass, error): ) with patch( - "aioshelly.get_info", return_value={"mac": "test-mac", "auth": False} - ), patch("aioshelly.Device.create", new=AsyncMock(side_effect=exc)): + "aioshelly.common.get_info", return_value={"mac": "test-mac", "auth": False} + ), patch( + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -217,7 +238,7 @@ async def test_form_already_configured(hass): ) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ): result2 = await hass.config_entries.flow.async_configure( @@ -252,12 +273,13 @@ async def test_user_setup_ignored_device(hass): settings["fw"] = "20201124-092534/v1.9.0@57ac4ad8" with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=settings, ) ), @@ -287,7 +309,10 @@ async def test_form_firmware_unsupported(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported): + with patch( + "aioshelly.common.get_info", + side_effect=aioshelly.exceptions.FirmwareUnsupported, + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -313,14 +338,17 @@ async def test_form_auth_errors_test_connection(hass, error): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioshelly.get_info", return_value={"mac": "test-mac", "auth": True}): + with patch( + "aioshelly.common.get_info", + return_value={"mac": "test-mac", "auth": True}, + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, ) with patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc), ): result3 = await hass.config_entries.flow.async_configure( @@ -336,12 +364,13 @@ async def test_zeroconf(hass): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), @@ -378,6 +407,7 @@ async def test_zeroconf(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -388,7 +418,7 @@ async def test_zeroconf_sleeping_device(hass): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={ "mac": "test-mac", "type": "SHSW-1", @@ -396,9 +426,10 @@ async def test_zeroconf_sleeping_device(hass): "sleep_mode": True, }, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings={ "name": "Test name", "device": { @@ -442,6 +473,7 @@ async def test_zeroconf_sleeping_device(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 600, + "gen": 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -460,7 +492,7 @@ async def test_zeroconf_sleeping_device_error(hass, error): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={ "mac": "test-mac", "type": "SHSW-1", @@ -468,7 +500,7 @@ async def test_zeroconf_sleeping_device_error(hass, error): "sleep_mode": True, }, ), patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc), ): result = await hass.config_entries.flow.async_init( @@ -489,7 +521,7 @@ async def test_zeroconf_already_configured(hass): entry.add_to_hass(hass) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, ): result = await hass.config_entries.flow.async_init( @@ -506,7 +538,10 @@ async def test_zeroconf_already_configured(hass): async def test_zeroconf_firmware_unsupported(hass): """Test we abort if device firmware is unsupported.""" - with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported): + with patch( + "aioshelly.common.get_info", + side_effect=aioshelly.exceptions.FirmwareUnsupported, + ): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -519,7 +554,7 @@ async def test_zeroconf_firmware_unsupported(hass): async def test_zeroconf_cannot_connect(hass): """Test we get the form.""" - with patch("aioshelly.get_info", side_effect=asyncio.TimeoutError): + with patch("aioshelly.common.get_info", side_effect=asyncio.TimeoutError): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -534,7 +569,7 @@ async def test_zeroconf_require_auth(hass): await setup.async_setup_component(hass, "persistent_notification", {}) with patch( - "aioshelly.get_info", + "aioshelly.common.get_info", return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, ): result = await hass.config_entries.flow.async_init( @@ -546,9 +581,10 @@ async def test_zeroconf_require_auth(hass): assert result["errors"] == {} with patch( - "aioshelly.Device.create", + "aioshelly.block_device.BlockDevice.create", new=AsyncMock( return_value=Mock( + model="SHSW-1", settings=MOCK_SETTINGS, ) ), @@ -570,6 +606,7 @@ async def test_zeroconf_require_auth(hass): "host": "1.1.1.1", "model": "SHSW-1", "sleep_period": 0, + "gen": 1, "username": "test username", "password": "test password", } diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index bedf4abc0f2..67e4660d167 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -8,11 +8,11 @@ from homeassistant.components import automation from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.shelly import ShellyDeviceWrapper +from homeassistant.components.shelly import BlockDeviceWrapper from homeassistant.components.shelly.const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, - COAP, + BLOCK, CONF_SUBTYPE, DATA_CONFIG_ENTRY, DOMAIN, @@ -30,8 +30,8 @@ from tests.common import ( ) -async def test_get_triggers(hass, coap_wrapper): - """Test we get the expected triggers from a shelly.""" +async def test_get_triggers_block_device(hass, coap_wrapper): + """Test we get the expected triggers from a shelly block device.""" assert coap_wrapper expected_triggers = [ { @@ -57,6 +57,54 @@ async def test_get_triggers(hass, coap_wrapper): assert_lists_same(triggers, expected_triggers) +async def test_get_triggers_rpc_device(hass, rpc_wrapper): + """Test we get the expected triggers from a shelly RPC device.""" + assert rpc_wrapper + expected_triggers = [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "btn_down", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "btn_up", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "double_push", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "long_push", + CONF_SUBTYPE: "button1", + }, + ] + + triggers = await async_get_device_automations( + hass, "trigger", rpc_wrapper.device_id + ) + + assert_lists_same(triggers, expected_triggers) + + async def test_get_triggers_button(hass): """Test we get the expected triggers from a shelly button.""" await async_setup_component(hass, "shelly", {}) @@ -79,10 +127,10 @@ async def test_get_triggers_button(hass): hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, config_entry, device) + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) - await coap_wrapper.async_setup() + coap_wrapper.async_setup() expected_triggers = [ { @@ -136,8 +184,8 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper await async_get_device_automations(hass, "trigger", invalid_device.id) -async def test_if_fires_on_click_event(hass, calls, coap_wrapper): - """Test for click_event trigger firing.""" +async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): + """Test for click_event trigger firing for block device.""" assert coap_wrapper await setup.async_setup_component(hass, "persistent_notification", {}) @@ -175,8 +223,47 @@ async def test_if_fires_on_click_event(hass, calls, coap_wrapper): assert calls[0].data["some"] == "test_trigger_single_click" -async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): - """Test for click_event with no device.""" +async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): + """Test for click_event trigger firing for rpc device.""" + assert rpc_wrapper + await setup.async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single_push"}, + }, + }, + ] + }, + ) + + message = { + CONF_DEVICE_ID: rpc_wrapper.device_id, + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single_push" + + +async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper): + """Test validate trigger config when block device is not ready.""" assert coap_wrapper await setup.async_setup_component(hass, "persistent_notification", {}) @@ -189,7 +276,7 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: "no_device", + CONF_DEVICE_ID: "device_not_ready", CONF_TYPE: "single", CONF_SUBTYPE: "button1", }, @@ -201,7 +288,11 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): ] }, ) - message = {CONF_DEVICE_ID: "no_device", ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1} + message = { + CONF_DEVICE_ID: "device_not_ready", + ATTR_CLICK_TYPE: "single", + ATTR_CHANNEL: 1, + } hass.bus.async_fire(EVENT_SHELLY_CLICK, message) await hass.async_block_till_done() @@ -209,6 +300,44 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): assert calls[0].data["some"] == "test_trigger_single_click" +async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper): + """Test validate trigger config when RPC device is not ready.""" + assert rpc_wrapper + await setup.async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: "device_not_ready", + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single_push"}, + }, + }, + ] + }, + ) + message = { + CONF_DEVICE_ID: "device_not_ready", + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single_push" + + async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): """Test for click_event with invalid triggers.""" assert coap_wrapper diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 9cfda9ddcaa..9ece9590cbb 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -13,8 +13,8 @@ from homeassistant.setup import async_setup_component from tests.components.logbook.test_init import MockLazyEventPartialState -async def test_humanify_shelly_click_event(hass, coap_wrapper): - """Test humanifying Shelly click event.""" +async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): + """Test humanifying Shelly click event for block device.""" assert coap_wrapper hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -51,12 +51,63 @@ async def test_humanify_shelly_click_event(hass, coap_wrapper): assert event1["name"] == "Shelly" assert event1["domain"] == DOMAIN assert ( - event1["message"] == "'single' click event for Test name channel 1 was fired." + event1["message"] + == "'single' click event for Test name channel 1 Input was fired." ) assert event2["name"] == "Shelly" assert event2["domain"] == DOMAIN assert ( event2["message"] - == "'long' click event for shellyswitch25-12345678 channel 2 was fired." + == "'long' click event for shellyswitch25-12345678 channel 2 Input was fired." + ) + + +async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper): + """Test humanifying Shelly click event for rpc device.""" + assert rpc_wrapper + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + entity_attr_cache = logbook.EntityAttributeCache(hass) + + event1, event2 = list( + logbook.humanify( + hass, + [ + MockLazyEventPartialState( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: rpc_wrapper.device_id, + ATTR_DEVICE: "shellyplus1pm-12345678", + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + }, + ), + MockLazyEventPartialState( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: "no_device_id", + ATTR_DEVICE: "shellypro4pm-12345678", + ATTR_CLICK_TYPE: "btn_down", + ATTR_CHANNEL: 2, + }, + ), + ], + entity_attr_cache, + {}, + ) + ) + + assert event1["name"] == "Shelly" + assert event1["domain"] == DOMAIN + assert ( + event1["message"] + == "'single_push' click event for test switch_0 Input was fired." + ) + + assert event2["name"] == "Shelly" + assert event2["domain"] == DOMAIN + assert ( + event2["message"] + == "'btn_down' click event for shellypro4pm-12345678 channel 2 Input was fired." ) diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index b1dcc05bb80..fc61102507b 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -11,8 +11,8 @@ from homeassistant.const import ( RELAY_BLOCK_ID = 0 -async def test_services(hass, coap_wrapper): - """Test device turn on/off services.""" +async def test_block_device_services(hass, coap_wrapper): + """Test block device turn on/off services.""" assert coap_wrapper hass.async_create_task( @@ -37,8 +37,8 @@ async def test_services(hass, coap_wrapper): assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF -async def test_update(hass, coap_wrapper, monkeypatch): - """Test device update.""" +async def test_block_device_update(hass, coap_wrapper, monkeypatch): + """Test block device update.""" assert coap_wrapper hass.async_create_task( @@ -61,8 +61,8 @@ async def test_update(hass, coap_wrapper, monkeypatch): assert hass.states.get("switch.test_name_channel_1").state == STATE_ON -async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): - """Test device without relay blocks.""" +async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch): + """Test block device without relay blocks.""" assert coap_wrapper monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller") @@ -73,8 +73,8 @@ async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): assert hass.states.get("switch.test_name_channel_1") is None -async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): - """Test switch device in roller mode.""" +async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch): + """Test block device in roller mode.""" assert coap_wrapper monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") @@ -83,3 +83,61 @@ async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): ) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is None + + +async def test_block_device_app_type_light(hass, coap_wrapper, monkeypatch): + """Test block device in app type set to light mode.""" + assert coap_wrapper + + monkeypatch.setitem( + coap_wrapper.device.settings["relays"][0], "appliance_type", "light" + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("switch.test_name_channel_1") is None + + +async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): + """Test RPC device turn on/off services.""" + assert rpc_wrapper + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_0"}, + blocking=True, + ) + assert hass.states.get("switch.test_switch_0").state == STATE_ON + + monkeypatch.setitem(rpc_wrapper.device.status["switch:0"], "output", False) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_switch_0"}, + blocking=True, + ) + rpc_wrapper.async_set_updated_data("") + assert hass.states.get("switch.test_switch_0").state == STATE_OFF + + +async def test_rpc_device_switch_type_lights_mode(hass, rpc_wrapper, monkeypatch): + """Test RPC device with switch in consumption type lights mode.""" + assert rpc_wrapper + + monkeypatch.setitem( + rpc_wrapper.device.config["sys"]["ui_data"], + "consumption_types", + ["lights"], + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("switch.test_switch_0") is None diff --git a/tests/components/sia/test_config_flow.py b/tests/components/sia/test_config_flow.py index 9679f4949e8..a91822c7846 100644 --- a/tests/components/sia/test_config_flow.py +++ b/tests/components/sia/test_config_flow.py @@ -209,6 +209,13 @@ async def test_abort_form(hass): assert get_abort["reason"] == "already_configured" +@pytest.fixture(autouse=True) +def mock_sia(): + """Mock SIAClient.""" + with patch("homeassistant.components.sia.hub.SIAClient", autospec=True): + yield + + @pytest.mark.parametrize( "field, value, error", [ diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index bc9175a3b46..73fbf81fce0 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -395,7 +395,7 @@ async def test_abort_cloud_flow_if_local_device_exists(hass): async def test_full_user_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -422,7 +422,7 @@ async def test_full_user_flow( }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index ab937d266a4..2cf54ba7533 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -26,8 +26,12 @@ async def test_setup_entry( assert state -async def test_remove_entry(hass: HomeAssistant) -> None: +async def test_remove_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: """Test remove entry.""" + uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + aioclient_mock.get(uri, text=api_response) entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG) entry.add_to_hass(hass) diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py index 6a1c32e4138..bcfb617db96 100644 --- a/tests/components/somfy/test_config_flow.py +++ b/tests/components/somfy/test_config_flow.py @@ -34,7 +34,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -67,7 +67,7 @@ async def test_full_flow( f"&state={state}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 294e243901a..f650c6e8fef 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -39,6 +39,7 @@ class SonosMockEvent: base, count = self.variables[var_name].split(":") newcount = int(count) + 1 self.variables[var_name] = ":".join([base, str(newcount)]) + return self.variables[var_name] @pytest.fixture(name="config_entry") @@ -74,16 +75,28 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): yield mock_soco +@pytest.fixture(autouse=True) +async def silent_ssdp_scanner(hass): + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with patch( + "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" + ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( + "homeassistant.components.ssdp.Scanner.async_scan" + ): + yield + + @pytest.fixture(name="discover", autouse=True) def discover_fixture(soco): """Create a mock soco discover fixture.""" - def do_callback(hass, callback, *args, **kwargs): - callback( + async def do_callback(hass, callback, *args, **kwargs): + await callback( { ssdp.ATTR_UPNP_UDN: soco.uid, ssdp.ATTR_SSDP_LOCATION: f"http://{soco.ip_address}/", - } + }, + ssdp.SsdpChange.ALIVE, ) return MagicMock() @@ -102,8 +115,8 @@ def config_fixture(): @pytest.fixture(name="music_library") def music_library_fixture(): """Create music_library fixture.""" - music_library = Mock() - music_library.get_sonos_favorites.return_value = [] + music_library = MagicMock() + music_library.get_sonos_favorites.return_value.update_id = 1 return music_library @@ -113,12 +126,13 @@ def alarm_clock_fixture(): alarm_clock = SonosMockService("AlarmClock") alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms.return_value = { + "CurrentAlarmListVersion": "RINCON_test:14", "CurrentAlarmList": "" '' - " " + "", } return alarm_clock @@ -129,6 +143,7 @@ def alarm_clock_fixture_extended(): alarm_clock = SonosMockService("AlarmClock") alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms.return_value = { + "CurrentAlarmListVersion": "RINCON_test:15", "CurrentAlarmList": "" '' - " " + "", } return alarm_clock @@ -148,6 +163,7 @@ def speaker_info_fixture(): """Create speaker_info fixture.""" return { "zone_name": "Zone A", + "uid": "RINCON_test", "model_name": "Model Name", "software_version": "49.2-64250", "mac_address": "00-11-22-33-44-55", @@ -190,3 +206,9 @@ def alarm_event_fixture(soco): } return SonosMockEvent(soco, soco.alarmClock, variables) + + +@pytest.fixture(autouse=True) +def mock_get_source_ip(mock_get_source_ip): + """Mock network util's async_get_source_ip in all sonos tests.""" + return mock_get_source_ip diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 90ffdb155ea..faf4e07ac9c 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -45,6 +45,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): context={"source": config_entries.SOURCE_ZEROCONF}, data={ "host": "192.168.4.2", + "name": "Sonos-aaa@Living Room._sonos._tcp.local.", "hostname": "Sonos-aaa", "properties": {"bootseq": "1234"}, }, diff --git a/tests/components/sonos/test_helpers.py b/tests/components/sonos/test_helpers.py index 858657e01c0..a52337f9455 100644 --- a/tests/components/sonos/test_helpers.py +++ b/tests/components/sonos/test_helpers.py @@ -1,15 +1,7 @@ """Test the sonos config flow.""" from __future__ import annotations -from homeassistant.components.sonos.helpers import ( - hostname_to_uid, - uid_to_short_hostname, -) - - -async def test_uid_to_short_hostname(): - """Test we can convert a uid to a short hostname.""" - assert uid_to_short_hostname("RINCON_347E5C0CF1E301400") == "Sonos-347E5C0CF1E3" +from homeassistant.components.sonos.helpers import hostname_to_uid async def test_uid_to_hostname(): diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 0e4af2071b2..4de0f37d333 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -77,7 +77,10 @@ async def test_device_registry(hass, config_entry, config, soco): ) assert reg_device.model == "Model Name" assert reg_device.sw_version == "13.1" - assert reg_device.connections == {(dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55")} + assert reg_device.connections == { + (dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), + (dr.CONNECTION_UPNP, "uuid:RINCON_test"), + } assert reg_device.manufacturer == "Sonos" assert reg_device.suggested_area == "Zone A" assert reg_device.name == "Zone A" diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index f684a8f351e..d71d403fd8a 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -69,13 +69,17 @@ async def test_alarm_create_delete( alarm_clock.ListAlarms.return_value = two_alarms + alarm_event.variables["alarm_list_version"] = two_alarms["CurrentAlarmListVersion"] + sub_callback(event=alarm_event) await hass.async_block_till_done() assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" in entity_registry.entities - alarm_event.increment_variable("alarm_list_version") + one_alarm["CurrentAlarmListVersion"] = alarm_event.increment_variable( + "alarm_list_version" + ) alarm_clock.ListAlarms.return_value = one_alarm diff --git a/tests/components/speedtestdotnet/__init__.py b/tests/components/speedtestdotnet/__init__.py index f6f64b9c7bb..b5e297f25da 100644 --- a/tests/components/speedtestdotnet/__init__.py +++ b/tests/components/speedtestdotnet/__init__.py @@ -52,4 +52,4 @@ MOCK_RESULTS = { "share": None, } -MOCK_STATES = {"ping": "18.465", "download": "1.02", "upload": "1.02"} +MOCK_STATES = {"ping": "18", "download": "1.02", "upload": "1.02"} diff --git a/tests/components/speedtestdotnet/test_config_flow.py b/tests/components/speedtestdotnet/test_config_flow.py index 727a5778603..7f6f6970c4d 100644 --- a/tests/components/speedtestdotnet/test_config_flow.py +++ b/tests/components/speedtestdotnet/test_config_flow.py @@ -2,8 +2,6 @@ from datetime import timedelta from unittest.mock import MagicMock -from speedtest import NoMatchedServers - from homeassistant import config_entries, data_entry_flow from homeassistant.components import speedtestdotnet from homeassistant.components.speedtestdotnet.const import ( @@ -11,9 +9,8 @@ from homeassistant.components.speedtestdotnet.const import ( CONF_SERVER_ID, CONF_SERVER_NAME, DOMAIN, - SENSOR_TYPES, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,45 +30,6 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_import_fails(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test import step fails if server_id is not valid.""" - - 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): - """Test import step is successful if server_id is valid.""" - - 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 - - async def test_options(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test updating options.""" entry = MockConfigEntry( @@ -107,6 +65,28 @@ async def test_options(hass: HomeAssistant, mock_api: MagicMock) -> None: assert hass.data[DOMAIN].update_interval is None + # test setting server name to "*Auto Detect" + 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: "*Auto Detect", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_SERVER_NAME: "*Auto Detect", + CONF_SERVER_ID: None, + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + } + # 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 diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index fcadb0e9931..61487ca8329 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -1,4 +1,5 @@ """Tests for SpeedTest integration.""" +from datetime import timedelta from unittest.mock import MagicMock import speedtest @@ -13,8 +14,9 @@ from homeassistant.components.speedtestdotnet.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_SCAN_INTERVAL, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_successful_config_entry(hass: HomeAssistant) -> None: @@ -74,6 +76,10 @@ async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> Non entry = MockConfigEntry( domain=DOMAIN, + options={ + CONF_MANUAL: False, + CONF_SCAN_INTERVAL: 60, + }, ) entry.add_to_hass(hass) @@ -82,7 +88,10 @@ async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> Non assert hass.data[DOMAIN] mock_api.return_value.get_servers.side_effect = speedtest.NoMatchedServers - await hass.data[DOMAIN].async_refresh() + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=entry.options[CONF_SCAN_INTERVAL] + 1), + ) await hass.async_block_till_done() state = hass.states.get("sensor.speedtest_ping") assert state is not None diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index d0378731c28..06802a6cae7 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,12 +3,16 @@ 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 homeassistant.components.speedtestdotnet.const import ( + CONF_MANUAL, + DEFAULT_NAME, + SENSOR_TYPES, +) +from homeassistant.core import HomeAssistant, State from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache async def test_speedtestdotnet_sensors( @@ -30,3 +34,28 @@ async def test_speedtestdotnet_sensors( sensor = hass.states.get(f"sensor.{DEFAULT_NAME}_{description.name}") assert sensor assert sensor.state == MOCK_STATES[description.key] + + +async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test restoring last state for sensors.""" + mock_restore_cache( + hass, + [ + State(f"sensor.speedtest_{sensor}", state) + for sensor, state in MOCK_STATES.items() + ], + ) + entry = MockConfigEntry( + domain=speedtestdotnet.DOMAIN, data={}, options={CONF_MANUAL: True} + ) + entry.add_to_hass(hass) + + 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 + + for description in SENSOR_TYPES: + sensor = hass.states.get(f"sensor.speedtest_{description.name}") + assert sensor + assert sensor.state == MOCK_STATES[description.key] diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cd0be3f7cc8..0d0d4a50a3d 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -42,7 +42,7 @@ async def test_zeroconf_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check a full flow.""" assert await setup.async_setup_component( @@ -78,7 +78,7 @@ async def test_full_flow( "user-top-read,user-read-playback-position,user-read-recently-played,user-follow-read" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -93,7 +93,9 @@ async def test_full_flow( }, ) - with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: + with patch( + "homeassistant.components.spotify.async_setup_entry", return_value=True + ), patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: spotify_mock.return_value.current_user.return_value = { "id": "fake_id", "display_name": "frenck", @@ -112,7 +114,7 @@ async def test_full_flow( async def test_abort_if_spotify_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check Spotify errors causes flow to abort.""" await setup.async_setup_component( @@ -136,7 +138,7 @@ async def test_abort_if_spotify_error( "redirect_uri": "https://example.com/auth/external/callback", }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( @@ -160,7 +162,7 @@ async def test_abort_if_spotify_error( async def test_reauthentication( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test Spotify reauthentication.""" await setup.async_setup_component( @@ -197,7 +199,7 @@ async def test_reauthentication( "redirect_uri": "https://example.com/auth/external/callback", }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( @@ -210,7 +212,9 @@ async def test_reauthentication( }, ) - with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: + with patch( + "homeassistant.components.spotify.async_setup_entry", return_value=True + ), patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: spotify_mock.return_value.current_user.return_value = {"id": "frenck"} result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -225,7 +229,7 @@ async def test_reauthentication( async def test_reauth_account_mismatch( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test Spotify reauthentication with different account.""" await setup.async_setup_component( @@ -260,7 +264,7 @@ async def test_reauth_account_mismatch( "redirect_uri": "https://example.com/auth/external/callback", }, ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index c8d458dfb82..54f8629a980 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -93,13 +93,21 @@ async def test_form_unknown_exception(hass): async def test_config(hass): """Test handling of configuration imported.""" - with patch("homeassistant.components.srp_energy.config_flow.SrpEnergyClient"): + with patch( + "homeassistant.components.srp_energy.config_flow.SrpEnergyClient" + ), patch( + "homeassistant.components.srp_energy.async_setup_entry", + return_value=True, + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=ENTRY_CONFIG, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 async def test_integration_already_configured(hass): diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py new file mode 100644 index 00000000000..0b390ae469b --- /dev/null +++ b/tests/components/ssdp/conftest.py @@ -0,0 +1,25 @@ +"""Configuration for SSDP tests.""" +from unittest.mock import AsyncMock, patch + +from async_upnp_client.ssdp_listener import SsdpListener +import pytest + + +@pytest.fixture(autouse=True) +async def silent_ssdp_listener(): + """Patch SsdpListener class, preventing any actual SSDP traffic.""" + with patch("homeassistant.components.ssdp.SsdpListener.async_start"), patch( + "homeassistant.components.ssdp.SsdpListener.async_stop" + ), patch("homeassistant.components.ssdp.SsdpListener.async_search"): + # Fixtures are initialized before patches. When the component is started here, + # certain functions/methods might not be patched in time. + yield SsdpListener + + +@pytest.fixture +def mock_flow_init(hass): + """Mock hass.config_entries.flow.async_init.""" + with patch.object( + hass.config_entries.flow, "async_init", return_value=AsyncMock() + ) as mock_init: + yield mock_init diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index b285d3b0f3c..f3ddab39c39 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,14 +1,14 @@ """Test the SSDP integration.""" -import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address -from unittest.mock import patch +from unittest.mock import ANY, AsyncMock, patch -import aiohttp -from async_upnp_client.search import SSDPListener +from async_upnp_client.ssdp import udn_from_headers +from async_upnp_client.ssdp_listener import SsdpListener from async_upnp_client.utils import CaseInsensitiveDict import pytest +import homeassistant from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import ( @@ -16,125 +16,163 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, MATCH_ALL, ) -from homeassistant.core import CoreState, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, mock_coro +from tests.common import async_fire_time_changed -def _patched_ssdp_listener(info, *args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - await listener.async_callback(info) - - @callback - def _async_search(*_): - # Prevent an actual scan. - pass - - listener.async_start = _async_callback - listener.async_search = _async_search - return listener +def _ssdp_headers(headers): + return CaseInsensitiveDict( + headers, _timestamp=datetime(2021, 1, 1, 12, 00), _udn=udn_from_headers(headers) + ) -async def _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp): - def _generate_fake_ssdp_listener(*args, **kwargs): - return _patched_ssdp_listener( - mock_ssdp_response, - *args, - **kwargs, - ) - - with patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value=mock_get_ssdp, - ), patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - await hass.async_block_till_done() - - return mock_init +async def init_ssdp_component(hass: homeassistant) -> SsdpListener: + """Initialize ssdp component and get SsdpListener.""" + await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + return hass.data[ssdp.DOMAIN]._ssdp_listeners[0] -async def test_scan_match_st(hass, caplog): +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.usefixtures("mock_get_source_ip") +async def test_ssdp_flow_dispatched_on_st(mock_get_ssdp, hass, caplog, mock_flow_init): """Test matching based on ST.""" - mock_ssdp_response = { - "st": "mock-st", - "location": None, - "usn": "mock-usn", - "server": "mock-server", - "ext": "", - } - mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": None, + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } - assert mock_init.mock_calls[0][2]["data"] == { + assert mock_flow_init.mock_calls[0][2]["data"] == { ssdp.ATTR_SSDP_ST: "mock-st", ssdp.ATTR_SSDP_LOCATION: None, - ssdp.ATTR_SSDP_USN: "mock-usn", + ssdp.ATTR_SSDP_USN: "uuid:mock-udn::mock-st", ssdp.ATTR_SSDP_SERVER: "mock-server", ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + ssdp.ATTR_SSDP_UDN: ANY, + "_timestamp": ANY, } assert "Failed to fetch ssdp data" not in caplog.text -async def test_partial_response(hass, caplog): - """Test location and st missing.""" - mock_ssdp_response = { - "usn": "mock-usn", - "server": "mock-server", - "ext": "", - } - mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert len(mock_init.mock_calls) == 0 - - -@pytest.mark.parametrize( - "key", (ssdp.ATTR_UPNP_MANUFACTURER, ssdp.ATTR_UPNP_DEVICE_TYPE) +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"manufacturer": "Paulus"}]}, ) -async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): +async def test_scan_match_upnp_devicedesc_manufacturer( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): """Test matching based on UPnP device description data.""" aioclient_mock.get( "http://1.1.1.1", - text=f""" + text=""" - <{key}>Paulus + Paulus """, ) - mock_get_ssdp = {"mock-domain": [{key: "Paulus"}]} - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + # If we get duplicate response, ensure we only look it up once assert len(aioclient_mock.mock_calls) == 1 - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } -async def test_scan_not_all_present(hass, aioclient_mock): +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"deviceType": "Paulus"}]}, +) +async def test_scan_match_upnp_devicedesc_devicetype( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): + """Test matching based on UPnP device description data.""" + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + + + """, + ) + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # If we get duplicate response, ensure we only look it up once + assert len(aioclient_mock.mock_calls) == 1 + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + + +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + } + ] + }, +) +async def test_scan_not_all_present( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): """Test match fails if some specified attributes are not present.""" aioclient_mock.get( "http://1.1.1.1", @@ -146,24 +184,34 @@ async def test_scan_not_all_present(hass, aioclient_mock): """, ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + } + ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert not mock_flow_init.mock_calls + + +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ "mock-domain": [ { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", } ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - -async def test_scan_not_all_match(hass, aioclient_mock): + }, +) +async def test_scan_not_all_match(mock_get_ssdp, hass, aioclient_mock, mock_flow_init): """Test match fails if some specified attribute values differ.""" aioclient_mock.get( "http://1.1.1.1", @@ -176,186 +224,124 @@ async def test_scan_not_all_match(hass, aioclient_mock): """, ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", - } - ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - -@pytest.mark.parametrize("exc", [asyncio.TimeoutError, aiohttp.ClientError]) -async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): - """Test failing to fetch description.""" - aioclient_mock.get("http://1.1.1.1", exc=exc) - mock_ssdp_response = { - "st": "mock-st", - "usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", - } - ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls - - assert ssdp.async_get_discovery_info_by_st(hass, "mock-st") == [ + mock_ssdp_search_response = _ssdp_headers( { - "UDN": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - "ssdp_location": "http://1.1.1.1", - "ssdp_st": "mock-st", - "ssdp_usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", } - ] - - -async def test_scan_description_parse_fail(hass, aioclient_mock): - """Test invalid XML.""" - aioclient_mock.get( - "http://1.1.1.1", - text=""" -INVALIDXML - """, ) + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", - } - ] - } - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert not mock_init.mock_calls + assert not mock_flow_init.mock_calls -async def test_invalid_characters(hass, aioclient_mock): - """Test that we replace bad characters with placeholders.""" +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"deviceType": "Paulus"}]}, +) +async def test_flow_start_only_alive( + mock_get_ssdp, hass, aioclient_mock, mock_flow_init +): + """Test config flow is only started for alive devices.""" aioclient_mock.get( "http://1.1.1.1", text=""" - ABC - \xff\xff\xff\xff + Paulus """, ) - - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", - } - ] - } - - mock_init = await _async_run_mocked_scan(hass, mock_ssdp_response, mock_get_ssdp) - - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert mock_init.mock_calls[0][2]["data"] == { - "ssdp_location": "http://1.1.1.1", - "ssdp_st": "mock-st", - "deviceType": "ABC", - "serialNumber": "ÿÿÿÿ", - } - - -@patch("homeassistant.components.ssdp.SSDPListener.async_start") -@patch("homeassistant.components.ssdp.SSDPListener.async_search") -@patch("homeassistant.components.ssdp.SSDPListener.async_stop") -async def test_start_stop_scanner( - async_stop_mock, async_search_mock, async_start_mock, hass -): - """Test we start and stop the scanner.""" - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - + ssdp_listener = await init_ssdp_component(hass) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + + # Search should start a flow + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + } + ) + await ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + + mock_flow_init.assert_awaited_once_with( + "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + ) + + # ssdp:alive advertisement should start a flow + mock_flow_init.reset_mock() + mock_ssdp_advertisement = _ssdp_headers( + { + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "nt": "upnp:rootdevice", + "nts": "ssdp:alive", + } + ) + await ssdp_listener._on_alive(mock_ssdp_advertisement) + await hass.async_block_till_done() + mock_flow_init.assert_awaited_once_with( + "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + ) + + # ssdp:byebye advertisement should not start a flow + mock_flow_init.reset_mock() + mock_ssdp_advertisement["nts"] = "ssdp:byebye" + await ssdp_listener._on_byebye(mock_ssdp_advertisement) + await hass.async_block_till_done() + mock_flow_init.assert_not_called() + + # ssdp:update advertisement should start a flow + mock_flow_init.reset_mock() + mock_ssdp_advertisement["nts"] = "ssdp:update" + await ssdp_listener._on_update(mock_ssdp_advertisement) + await hass.async_block_till_done() + mock_flow_init.assert_awaited_once_with( + "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + ) + + +@patch( # XXX TODO: Isn't this duplicate with mock_get_source_ip? + "homeassistant.components.ssdp.Scanner._async_build_source_set", + return_value={IPv4Address("192.168.1.1")}, +) +@pytest.mark.usefixtures("mock_get_source_ip") +async def test_start_stop_scanner(mock_source_set, hass): + """Test we start and stop the scanner.""" + ssdp_listener = await init_ssdp_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 1 - # Next is 2, as async_upnp_client triggers 1 SSDPListener._async_on_connect - assert async_search_mock.call_count == 2 - assert async_stop_mock.call_count == 0 + assert ssdp_listener.async_start.call_count == 1 + assert ssdp_listener.async_search.call_count == 4 + assert ssdp_listener.async_stop.call_count == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 1 - assert async_search_mock.call_count == 2 - assert async_stop_mock.call_count == 1 + assert ssdp_listener.async_start.call_count == 1 + assert ssdp_listener.async_search.call_count == 4 + assert ssdp_listener.async_stop.call_count == 1 -async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog): - """Test unexpected exception while fetching.""" - aioclient_mock.get( - "http://1.1.1.1", - text=""" - - - ABC - \xff\xff\xff\xff - - - """, - ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - } - mock_get_ssdp = { - "mock-domain": [ - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", - } - ] - } - - with patch( - "homeassistant.components.ssdp.descriptions.ElementTree.fromstring", - side_effect=ValueError, - ): - mock_init = await _async_run_mocked_scan( - hass, mock_ssdp_response, mock_get_ssdp - ) - - assert len(mock_init.mock_calls) == 0 - assert "Failed to fetch ssdp data from: http://1.1.1.1" in caplog.text - - -async def test_scan_with_registered_callback(hass, aioclient_mock, caplog): +@pytest.mark.usefixtures("mock_get_source_ip") +@patch("homeassistant.components.ssdp.async_get_ssdp", return_value={}) +async def test_scan_with_registered_callback( + mock_get_ssdp, hass, aioclient_mock, caplog +): """Test matching based on callback.""" aioclient_mock.get( "http://1.1.1.1", @@ -367,219 +353,86 @@ async def test_scan_with_registered_callback(hass, aioclient_mock, caplog): """, ) - mock_ssdp_response = { - "st": "mock-st", - "location": "http://1.1.1.1", - "usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - "server": "mock-server", - "x-rincon-bootseq": "55", - "ext": "", - } - not_matching_integration_callbacks = [] - integration_match_all_callbacks = [] - integration_match_all_not_present_callbacks = [] - integration_callbacks = [] - integration_callbacks_from_cache = [] - match_any_callbacks = [] + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", + "server": "mock-server", + "x-rincon-bootseq": "55", + "ext": "", + } + ) + ssdp_listener = await init_ssdp_component(hass) - @callback - def _async_exception_callbacks(info): - raise ValueError + async_exception_callback = AsyncMock(side_effect=ValueError) + await ssdp.async_register_callback(hass, async_exception_callback, {}) - @callback - def _async_integration_callbacks(info): - integration_callbacks.append(info) + async_integration_callback = AsyncMock() + await ssdp.async_register_callback( + hass, async_integration_callback, {"st": "mock-st"} + ) - @callback - def _async_integration_match_all_callbacks(info): - integration_match_all_callbacks.append(info) + async_integration_match_all_callback1 = AsyncMock() + await ssdp.async_register_callback( + hass, async_integration_match_all_callback1, {"x-rincon-bootseq": MATCH_ALL} + ) - @callback - def _async_integration_match_all_not_present_callbacks(info): - integration_match_all_not_present_callbacks.append(info) + async_integration_match_all_not_present_callback1 = AsyncMock() + await ssdp.async_register_callback( + hass, + async_integration_match_all_not_present_callback1, + {"x-not-there": MATCH_ALL}, + ) - @callback - def _async_integration_callbacks_from_cache(info): - integration_callbacks_from_cache.append(info) + async_not_matching_integration_callback1 = AsyncMock() + await ssdp.async_register_callback( + hass, async_not_matching_integration_callback1, {"st": "not-match-mock-st"} + ) - @callback - def _async_not_matching_integration_callbacks(info): - not_matching_integration_callbacks.append(info) + async_match_any_callback1 = AsyncMock() + await ssdp.async_register_callback(hass, async_match_any_callback1) - @callback - def _async_match_any_callbacks(info): - match_any_callbacks.append(info) + await hass.async_block_till_done() + await ssdp_listener._on_search(mock_ssdp_search_response) - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - await listener.async_callback(mock_ssdp_response) - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ): - hass.state = CoreState.stopped - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - ssdp.async_register_callback(hass, _async_exception_callbacks, {}) - ssdp.async_register_callback( - hass, - _async_integration_callbacks, - {"st": "mock-st"}, - ) - ssdp.async_register_callback( - hass, - _async_integration_match_all_callbacks, - {"x-rincon-bootseq": MATCH_ALL}, - ) - ssdp.async_register_callback( - hass, - _async_integration_match_all_not_present_callbacks, - {"x-not-there": MATCH_ALL}, - ) - ssdp.async_register_callback( - hass, - _async_not_matching_integration_callbacks, - {"st": "not-match-mock-st"}, - ) - ssdp.async_register_callback( - hass, - _async_match_any_callbacks, - ) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - ssdp.async_register_callback( - hass, - _async_integration_callbacks_from_cache, - {"st": "mock-st"}, - ) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - hass.state = CoreState.running - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert hass.state == CoreState.running - - assert len(integration_callbacks) == 5 - assert len(integration_callbacks_from_cache) == 5 - assert len(integration_match_all_callbacks) == 5 - assert len(integration_match_all_not_present_callbacks) == 0 - assert len(match_any_callbacks) == 5 - assert len(not_matching_integration_callbacks) == 0 - assert integration_callbacks[0] == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - "x-rincon-bootseq": "55", - } + assert async_integration_callback.call_count == 1 + assert async_integration_match_all_callback1.call_count == 1 + assert async_integration_match_all_not_present_callback1.call_count == 0 + assert async_match_any_callback1.call_count == 1 + assert async_not_matching_integration_callback1.call_count == 0 + assert async_integration_callback.call_args[0] == ( + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + "x-rincon-bootseq": "55", + ssdp.ATTR_SSDP_UDN: ANY, + "_timestamp": ANY, + }, + ssdp.SsdpChange.ALIVE, + ) assert "Failed to callback info" in caplog.text - -async def test_unsolicited_ssdp_registered_callback(hass, aioclient_mock, caplog): - """Test matching based on callback can handle unsolicited ssdp traffic without st.""" - aioclient_mock.get( - "http://10.6.9.12:1400/xml/device_description.xml", - text=""" - - - Paulus - - - """, + async_integration_callback_from_cache = AsyncMock() + await ssdp.async_register_callback( + hass, async_integration_callback_from_cache, {"st": "mock-st"} ) - mock_ssdp_response = { - "location": "http://10.6.9.12:1400/xml/device_description.xml", - "nt": "uuid:RINCON_1111BB963FD801400", - "nts": "ssdp:alive", - "server": "Linux UPnP/1.0 Sonos/63.2-88230 (ZPS12)", - "usn": "uuid:RINCON_1111BB963FD801400", - "x-rincon-household": "Sonos_dfjfkdghjhkjfhkdjfhkd", - "x-rincon-bootseq": "250", - "bootid.upnp.org": "250", - "x-rincon-wifimode": "0", - "x-rincon-variant": "1", - "household.smartspeaker.audio": "Sonos_v3294823948542543534", - } - integration_callbacks = [] - @callback - def _async_integration_callbacks(info): - integration_callbacks.append(info) - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - await listener.async_callback(mock_ssdp_response) - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - listener.async_start = _async_callback - listener.async_search = _callback - return listener - - with patch( - "homeassistant.components.ssdp.SSDPListener", - new=_generate_fake_ssdp_listener, - ): - hass.state = CoreState.stopped - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - ssdp.async_register_callback( - hass, - _async_integration_callbacks, - {"nts": "ssdp:alive", "x-rincon-bootseq": MATCH_ALL}, - ) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - hass.state = CoreState.running - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert hass.state == CoreState.running - - assert ( - len(integration_callbacks) == 4 - ) # unsolicited callbacks without st are not cached - assert integration_callbacks[0] == { - "UDN": "uuid:RINCON_1111BB963FD801400", - "bootid.upnp.org": "250", - "deviceType": "Paulus", - "household.smartspeaker.audio": "Sonos_v3294823948542543534", - "nt": "uuid:RINCON_1111BB963FD801400", - "nts": "ssdp:alive", - "ssdp_location": "http://10.6.9.12:1400/xml/device_description.xml", - "ssdp_server": "Linux UPnP/1.0 Sonos/63.2-88230 (ZPS12)", - "ssdp_usn": "uuid:RINCON_1111BB963FD801400", - "x-rincon-bootseq": "250", - "x-rincon-household": "Sonos_dfjfkdghjhkjfhkdjfhkd", - "x-rincon-variant": "1", - "x-rincon-wifimode": "0", - } - assert "Failed to callback info" not in caplog.text + assert async_integration_callback_from_cache.call_count == 1 -async def test_scan_second_hit(hass, aioclient_mock, caplog): - """Test matching on second scan.""" +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): + """Test getting existing/previously scanned headers.""" aioclient_mock.get( "http://1.1.1.1", text=""" @@ -590,9 +443,8 @@ async def test_scan_second_hit(hass, aioclient_mock, caplog): """, ) - - mock_ssdp_response = CaseInsensitiveDict( - **{ + mock_ssdp_search_response = _ssdp_headers( + { "ST": "mock-st", "LOCATION": "http://1.1.1.1", "USN": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", @@ -600,121 +452,59 @@ async def test_scan_second_hit(hass, aioclient_mock, caplog): "EXT": "", } ) - mock_get_ssdp = {"mock-domain": [{"st": "mock-st"}]} - integration_callbacks = [] + ssdp_listener = await init_ssdp_component(hass) + await ssdp_listener._on_search(mock_ssdp_search_response) - @callback - def _async_integration_callbacks(info): - integration_callbacks.append(info) + discovery_info_by_st = await ssdp.async_get_discovery_info_by_st(hass, "mock-st") + assert discovery_info_by_st == [ + { + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_SSDP_UDN: ANY, + "_timestamp": ANY, + } + ] - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - 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_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: - assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - remove = ssdp.async_register_callback( - hass, - _async_integration_callbacks, - {"st": "mock-st"}, - ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - remove() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - - assert len(integration_callbacks) == 4 - assert integration_callbacks[0] == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - } - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert mock_init.mock_calls[0][2]["data"] == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - } - assert "Failed to fetch ssdp data" not in caplog.text - udn_discovery_info = ssdp.async_get_discovery_info_by_st(hass, "mock-st") - discovery_info = udn_discovery_info[0] - assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" - assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" - assert ( - discovery_info[ssdp.ATTR_UPNP_UDN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" - ) - assert ( - discovery_info[ssdp.ATTR_SSDP_USN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" - ) - - st_discovery_info = ssdp.async_get_discovery_info_by_udn( + discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) - discovery_info = st_discovery_info[0] - assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" - assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" - assert ( - discovery_info[ssdp.ATTR_UPNP_UDN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" - ) - assert ( - discovery_info[ssdp.ATTR_SSDP_USN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" - ) + assert discovery_info_by_udn == [ + { + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_SSDP_UDN: ANY, + "_timestamp": ANY, + } + ] - discovery_info = ssdp.async_get_discovery_info_by_udn_st( + discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", "mock-st" ) - assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" - assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" - assert ( - discovery_info[ssdp.ATTR_UPNP_UDN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" - ) - assert ( - discovery_info[ssdp.ATTR_SSDP_USN] - == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" - ) + assert discovery_info_by_udn_st == { + ssdp.ATTR_SSDP_EXT: "", + ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", + ssdp.ATTR_SSDP_SERVER: "mock-server", + ssdp.ATTR_SSDP_ST: "mock-st", + ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_SSDP_UDN: ANY, + "_timestamp": ANY, + } - assert ssdp.async_get_discovery_info_by_udn_st(hass, "wrong", "mock-st") is None + assert ( + await ssdp.async_get_discovery_info_by_udn_st(hass, "wrong", "mock-st") is None + ) _ADAPTERS_WITH_MANUAL_CONFIG = [ @@ -752,408 +542,99 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -async def test_async_detect_interfaces_setting_empty_route(hass): +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + } + ] + }, +) +@patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, +) # XXX TODO: Isn't this duplicate with mock_get_source_ip? +async def test_async_detect_interfaces_setting_empty_route( + mock_get_adapters, mock_get_ssdp, hass +): """Test without default interface config and the route returns nothing.""" - mock_get_ssdp = { + await init_ssdp_component(hass) + + ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + source_ips = {ssdp_listener.source_ip for ssdp_listener in ssdp_listeners} + assert source_ips == {IPv6Address("2001:db8::"), IPv4Address("192.168.1.5")} + + +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ "mock-domain": [ { ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", } ] - } - create_args = [] - - def _generate_fake_ssdp_listener(*args, **kwargs): - create_args.append([args, kwargs]) - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - 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_fake_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"), None), - } - - -async def test_bind_failure_skips_adapter(hass, caplog): + }, +) +@patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, +) # XXX TODO: Isn't this duplicate with mock_get_source_ip? +async def test_bind_failure_skips_adapter( + mock_get_adapters, mock_get_ssdp, 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 = [] - search_args = [] - @callback - def _callback(*args): - nonlocal search_args - search_args.append(args) - pass + async def _async_start(self): + if self.source_ip == IPv6Address("2001:db8::"): + raise OSError - def _generate_failing_ssdp_listener(*args, **kwargs): - create_args.append([args, kwargs]) - listener = SSDPListener(*args, **kwargs) + SsdpListener.async_start = _async_start + await init_ssdp_component(hass) - 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"), 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 set(search_args) == { - (), - ( - ( - "255.255.255.255", - 1900, - ), - ), - } + ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + source_ips = {ssdp_listener.source_ip for ssdp_listener in ssdp_listeners} + assert source_ips == { + IPv4Address("192.168.1.5") + } # Note no SsdpListener for IPv6 address. -async def test_ipv4_does_additional_search_for_sonos(hass, caplog): - """Test that only ipv4 does an additional search for Sonos.""" - mock_get_ssdp = { +@pytest.mark.usefixtures("mock_get_source_ip") +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={ "mock-domain": [ { ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", } ] - } - search_args = [] - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*args): - nonlocal search_args - search_args.append(args) - 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_fake_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() - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - - assert set(search_args) == { - (), - ( - ( - "255.255.255.255", - 1900, - ), - ), - } - - -async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock): - """Test that a location change for a UDN will evict the prior location from the cache.""" - mock_get_ssdp = { - "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] - } - - hue_response = """ - - -1 -0 - -http://{ip_address}:80/ - -urn:schemas-upnp-org:device:Basic:1 -Philips hue ({ip_address}) -Signify -http://www.philips-hue.com -Philips hue Personal Wireless Lighting -Philips hue bridge 2015 -BSB002 -http://www.philips-hue.com -001788a36abf -uuid:2f402f80-da50-11e1-9b23-001788a36abf - - - """ - - aioclient_mock.get( - "http://192.168.212.23/description.xml", - text=hue_response.format(ip_address="192.168.212.23"), - ) - aioclient_mock.get( - "http://169.254.8.155/description.xml", - text=hue_response.format(ip_address="169.254.8.155"), - ) - ssdp_response_without_location = { - "ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", - "_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", - "USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", - "SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0", - "hue-bridgeid": "001788FFFEA36ABF", - "EXT": "", - } - - mock_good_ip_ssdp_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://192.168.212.23/description.xml"}, - ) - mock_link_local_ip_ssdp_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://169.254.8.155/description.xml"}, - ) - mock_ssdp_response = mock_good_ip_ssdp_response - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - 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_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_init: - 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() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "hue" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == mock_good_ip_ssdp_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = mock_link_local_ip_ssdp_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "hue" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == mock_link_local_ip_ssdp_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = mock_good_ip_ssdp_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "hue" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == mock_good_ip_ssdp_response["location"] - ) - - -async def test_location_change_with_overlapping_udn_st_combinations( - hass, aioclient_mock + }, +) +@patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, +) # XXX TODO: Isn't this duplicate with mock_get_source_ip? +async def test_ipv4_does_additional_search_for_sonos( + mock_get_adapters, mock_get_ssdp, hass ): - """Test handling when a UDN and ST broadcast multiple locations.""" - mock_get_ssdp = { - "test_integration": [ - {"manufacturer": "test_manufacturer", "modelName": "test_model"} - ] - } + """Test that only ipv4 does an additional search for Sonos.""" + ssdp_listener = await init_ssdp_component(hass) - hue_response = """ - - -test_manufacturer -test_model - - - """ + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() - aioclient_mock.get( - "http://192.168.72.1:49154/wps_device.xml", - text=hue_response.format(ip_address="192.168.72.1"), + assert ssdp_listener.async_search.call_count == 6 + assert ssdp_listener.async_search.call_args[0] == ( + ( + "255.255.255.255", + 1900, + ), ) - aioclient_mock.get( - "http://192.168.72.1:49152/wps_device.xml", - text=hue_response.format(ip_address="192.168.72.1"), - ) - ssdp_response_without_location = { - "ST": "upnp:rootdevice", - "_udn": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6", - "USN": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6::upnp:rootdevice", - "EXT": "", - } - - port_49154_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://192.168.72.1:49154/wps_device.xml"}, - ) - port_49152_response = CaseInsensitiveDict( - **ssdp_response_without_location, - **{"LOCATION": "http://192.168.72.1:49152/wps_device.xml"}, - ) - mock_ssdp_response = port_49154_response - - def _generate_fake_ssdp_listener(*args, **kwargs): - listener = SSDPListener(*args, **kwargs) - - async def _async_callback(*_): - pass - - @callback - def _callback(*_): - hass.async_create_task(listener.async_callback(mock_ssdp_response)) - - 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_fake_ssdp_listener, - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_init: - 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() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "test_integration" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == port_49154_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = port_49152_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "test_integration" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == port_49152_response["location"] - ) - - mock_init.reset_mock() - mock_ssdp_response = port_49154_response - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) - await hass.async_block_till_done() - assert mock_init.mock_calls[0][1][0] == "test_integration" - assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP - } - assert ( - mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] - == port_49154_response["location"] - ) + assert ssdp_listener.async_search.call_args[1] == {} diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index a39e8bdca21..19a4d2a9e6f 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -1,10 +1,25 @@ """Collection of test helpers.""" +from datetime import datetime from fractions import Fraction +from functools import partial import io import av import numpy as np +from homeassistant.components.stream.core import Segment + +FAKE_TIME = datetime.utcnow() +# Segment with defaults filled in for use in tests + +DefaultSegment = partial( + Segment, + init=None, + stream_id=0, + start_time=FAKE_TIME, + stream_outputs=[], +) + AUDIO_SAMPLE_RATE = 8000 @@ -22,14 +37,13 @@ def generate_audio_frame(pcm_mulaw=False): return audio_frame -def generate_h264_video(container_format="mp4"): +def generate_h264_video(container_format="mp4", duration=5): """ Generate a test video. See: http://docs.mikeboers.com/pyav/develop/cookbook/numpy.html """ - duration = 5 fps = 24 total_frames = duration * fps diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index a73678d763f..746cc05fcbd 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -17,11 +17,12 @@ import logging import threading from unittest.mock import patch +from aiohttp import web import async_timeout import pytest from homeassistant.components.stream import Stream -from homeassistant.components.stream.core import Segment +from homeassistant.components.stream.core import Segment, StreamOutput TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -120,3 +121,95 @@ def record_worker_sync(hass): autospec=True, ): yield sync + + +class HLSSync: + """Test fixture that intercepts stream worker calls to StreamOutput.""" + + def __init__(self): + """Initialize HLSSync.""" + self._request_event = asyncio.Event() + self._original_recv = StreamOutput.recv + self._original_part_recv = StreamOutput.part_recv + self._original_bad_request = web.HTTPBadRequest + self._original_not_found = web.HTTPNotFound + self._original_response = web.Response + self._num_requests = 0 + self._num_recvs = 0 + self._num_finished = 0 + + def reset_request_pool(self, num_requests: int, reset_finished=True): + """Use to reset the request counter between segments.""" + self._num_recvs = 0 + if reset_finished: + self._num_finished = 0 + self._num_requests = num_requests + + async def wait_for_handler(self): + """Set up HLSSync to block calls to put until requests are set up.""" + if not self.check_requests_ready(): + await self._request_event.wait() + self.reset_request_pool(num_requests=self._num_requests, reset_finished=False) + + def check_requests_ready(self): + """Unblock the pending put call if the requests are all finished or blocking.""" + if self._num_recvs + self._num_finished == self._num_requests: + self._request_event.set() + self._request_event.clear() + return True + return False + + def bad_request(self): + """Intercept the HTTPBadRequest call so we know when the web handler is finished.""" + self._num_finished += 1 + self.check_requests_ready() + return self._original_bad_request() + + def not_found(self): + """Intercept the HTTPNotFound call so we know when the web handler is finished.""" + self._num_finished += 1 + self.check_requests_ready() + return self._original_not_found() + + def response(self, body, headers, status=200): + """Intercept the Response call so we know when the web handler is finished.""" + self._num_finished += 1 + self.check_requests_ready() + return self._original_response(body=body, headers=headers, status=status) + + async def recv(self, output: StreamOutput, **kw): + """Intercept the recv call so we know when the response is blocking on recv.""" + self._num_recvs += 1 + self.check_requests_ready() + return await self._original_recv(output) + + async def part_recv(self, output: StreamOutput, **kw): + """Intercept the recv call so we know when the response is blocking on recv.""" + self._num_recvs += 1 + self.check_requests_ready() + return await self._original_part_recv(output) + + +@pytest.fixture() +def hls_sync(): + """Patch HLSOutput to allow test to synchronize playlist requests and responses.""" + sync = HLSSync() + with patch( + "homeassistant.components.stream.core.StreamOutput.recv", + side_effect=sync.recv, + autospec=True, + ), patch( + "homeassistant.components.stream.core.StreamOutput.part_recv", + side_effect=sync.part_recv, + autospec=True, + ), patch( + "homeassistant.components.stream.hls.web.HTTPBadRequest", + side_effect=sync.bad_request, + ), patch( + "homeassistant.components.stream.hls.web.HTTPNotFound", + side_effect=sync.not_found, + ), patch( + "homeassistant.components.stream.hls.web.Response", + side_effect=sync.response, + ): + yield sync diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 919f71c8509..da040f6646a 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -1,5 +1,5 @@ """The tests for hls streams.""" -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import patch from urllib.parse import urlparse @@ -8,17 +8,23 @@ import pytest from homeassistant.components.stream import create_stream from homeassistant.components.stream.const import ( + EXT_X_START_LL_HLS, + EXT_X_START_NON_LL_HLS, HLS_PROVIDER, MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS, ) -from homeassistant.components.stream.core import Part, Segment +from homeassistant.components.stream.core import Part from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import generate_h264_video +from tests.components.stream.common import ( + FAKE_TIME, + DefaultSegment as Segment, + generate_h264_video, +) STREAM_SOURCE = "some-stream-source" INIT_BYTES = b"init" @@ -26,7 +32,6 @@ FAKE_PAYLOAD = b"fake-payload" SEGMENT_DURATION = 10 TEST_TIMEOUT = 5.0 # Lower than 9s home assistant timeout MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever -FAKE_TIME = datetime.utcnow() class HlsClient: @@ -37,13 +42,13 @@ class HlsClient: self.http_client = http_client self.parsed_url = parsed_url - async def get(self, path=None): + async def get(self, path=None, headers=None): """Fetch the hls stream for the specified path.""" url = self.parsed_url.path if path: # Strip off the master playlist suffix and replace with path url = "/".join(self.parsed_url.path.split("/")[:-1]) + path - return await self.http_client.get(url) + return await self.http_client.get(url, headers=headers) @pytest.fixture @@ -60,36 +65,52 @@ def hls_stream(hass, hass_client): def make_segment(segment, discontinuity=False): """Create a playlist response for a segment.""" - response = [] - if discontinuity: - response.extend( - [ - "#EXT-X-DISCONTINUITY", - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - ] - ) - response.extend([f"#EXTINF:{SEGMENT_DURATION:.3f},", f"./segment/{segment}.m4s"]) + response = ["#EXT-X-DISCONTINUITY"] if discontinuity else [] + response.extend( + [ + "#EXT-X-PROGRAM-DATE-TIME:" + + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + "Z", + f"#EXTINF:{SEGMENT_DURATION:.3f},", + f"./segment/{segment}.m4s", + ] + ) return "\n".join(response) -def make_playlist(sequence, segments, discontinuity_sequence=0): +def make_playlist( + sequence, + discontinuity_sequence=0, + segments=None, + hint=None, + part_target_duration=None, +): """Create a an hls playlist response for tests to assert on.""" response = [ "#EXTM3U", "#EXT-X-VERSION:6", "#EXT-X-INDEPENDENT-SEGMENTS", '#EXT-X-MAP:URI="init.mp4"', - "#EXT-X-TARGETDURATION:10", + f"#EXT-X-TARGETDURATION:{SEGMENT_DURATION}", f"#EXT-X-MEDIA-SEQUENCE:{sequence}", f"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}", - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - f"#EXT-X-START:TIME-OFFSET=-{1.5*SEGMENT_DURATION:.3f}", ] - response.extend(segments) + if hint: + response.extend( + [ + f"#EXT-X-PART-INF:PART-TARGET={part_target_duration:.3f}", + f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*part_target_duration:.3f}", + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*part_target_duration:.3f},PRECISE=YES", + ] + ) + else: + response.append( + f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*SEGMENT_DURATION:.3f},PRECISE=YES", + ) + if segments: + response.extend(segments) + if hint: + response.append(hint) response.append("") return "\n".join(response) @@ -115,18 +136,23 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): hls_client = await hls_stream(stream) - # Fetch playlist - playlist_response = await hls_client.get() - assert playlist_response.status == 200 + # Fetch master playlist + master_playlist_response = await hls_client.get() + assert master_playlist_response.status == 200 # Fetch init - playlist = await playlist_response.text() + master_playlist = await master_playlist_response.text() init_response = await hls_client.get("/init.mp4") assert init_response.status == 200 + # Fetch playlist + playlist_url = "/" + master_playlist.splitlines()[-1] + playlist_response = await hls_client.get(playlist_url) + assert playlist_response.status == 200 + # Fetch segment playlist = await playlist_response.text() - segment_url = "/" + playlist.splitlines()[-1] + segment_url = "/" + [line for line in playlist.splitlines() if line][-1] segment_response = await hls_client.get(segment_url) assert segment_response.status == 200 @@ -243,7 +269,7 @@ async def test_stream_keepalive(hass): stream.stop() -async def test_hls_playlist_view_no_output(hass, hass_client, hls_stream): +async def test_hls_playlist_view_no_output(hass, hls_stream): """Test rendering the hls playlist with no output segments.""" await async_setup_component(hass, "stream", {"stream": {}}) @@ -265,7 +291,7 @@ async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) for i in range(2): - segment = Segment(sequence=i, duration=SEGMENT_DURATION, start_time=FAKE_TIME) + segment = Segment(sequence=i, duration=SEGMENT_DURATION) hls.put(segment) await hass.async_block_till_done() @@ -277,7 +303,7 @@ async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): sequence=0, segments=[make_segment(0), make_segment(1)] ) - segment = Segment(sequence=2, duration=SEGMENT_DURATION, start_time=FAKE_TIME) + segment = Segment(sequence=2, duration=SEGMENT_DURATION) hls.put(segment) await hass.async_block_till_done() resp = await hls_client.get("/playlist.m3u8") @@ -302,9 +328,7 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): # Produce enough segments to overfill the output buffer by one for sequence in range(MAX_SEGMENTS + 1): - segment = Segment( - sequence=sequence, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=sequence, duration=SEGMENT_DURATION) hls.put(segment) await hass.async_block_till_done() @@ -330,7 +354,8 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): ] # The segment that fell off the buffer is not accessible - segment_response = await hls_client.get("/segment/0.m4s") + with patch.object(hls.stream_settings, "hls_part_timeout", 0.1): + segment_response = await hls_client.get("/segment/0.m4s") assert segment_response.status == 404 # However all segments in the buffer are accessible, even those that were not in the playlist. @@ -350,19 +375,14 @@ async def test_hls_playlist_view_discontinuity(hass, hls_stream, stream_worker_s stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) - segment = Segment( - sequence=0, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=0, stream_id=0, duration=SEGMENT_DURATION) hls.put(segment) - segment = Segment( - sequence=1, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=1, stream_id=0, duration=SEGMENT_DURATION) hls.put(segment) segment = Segment( sequence=2, stream_id=1, duration=SEGMENT_DURATION, - start_time=FAKE_TIME, ) hls.put(segment) await hass.async_block_till_done() @@ -394,9 +414,7 @@ async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sy hls_client = await hls_stream(stream) - segment = Segment( - sequence=0, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME - ) + segment = Segment(sequence=0, stream_id=0, duration=SEGMENT_DURATION) hls.put(segment) # Produce enough segments to overfill the output buffer by one @@ -405,7 +423,6 @@ async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sy sequence=sequence, stream_id=1, duration=SEGMENT_DURATION, - start_time=FAKE_TIME, ) hls.put(segment) await hass.async_block_till_done() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py new file mode 100644 index 00000000000..ab1c01adce8 --- /dev/null +++ b/tests/components/stream/test_ll_hls.py @@ -0,0 +1,650 @@ +"""The tests for hls streams.""" +import asyncio +import itertools +import re +from urllib.parse import urlparse + +import pytest + +from homeassistant.components.stream import create_stream +from homeassistant.components.stream.const import ( + ATTR_SETTINGS, + CONF_LL_HLS, + CONF_PART_DURATION, + CONF_SEGMENT_DURATION, + DOMAIN, + HLS_PROVIDER, +) +from homeassistant.components.stream.core import Part +from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.setup import async_setup_component + +from .test_hls import SEGMENT_DURATION, STREAM_SOURCE, HlsClient, make_playlist + +from tests.components.stream.common import ( + FAKE_TIME, + DefaultSegment as Segment, + generate_h264_video, +) + +TEST_PART_DURATION = 1 +NUM_PART_SEGMENTS = int(-(-SEGMENT_DURATION // TEST_PART_DURATION)) +PART_INDEPENDENT_PERIOD = int(1 / TEST_PART_DURATION) or 1 +BYTERANGE_LENGTH = 1 +INIT_BYTES = b"init" +SEQUENCE_BYTES = bytearray(range(NUM_PART_SEGMENTS * BYTERANGE_LENGTH)) +ALT_SEQUENCE_BYTES = bytearray(range(20, 20 + NUM_PART_SEGMENTS * BYTERANGE_LENGTH)) +VERY_LARGE_LAST_BYTE_POS = 9007199254740991 + + +@pytest.fixture +def hls_stream(hass, hass_client): + """Create test fixture for creating an HLS client for a stream.""" + + async def create_client_for_stream(stream): + stream.ll_hls = True + http_client = await hass_client() + parsed_url = urlparse(stream.endpoint_url(HLS_PROVIDER)) + return HlsClient(http_client, parsed_url) + + return create_client_for_stream + + +def create_segment(sequence): + """Create an empty segment.""" + segment = Segment(sequence=sequence) + segment.init = INIT_BYTES + return segment + + +def complete_segment(segment): + """Completes a segment by setting its duration.""" + segment.duration = sum(part.duration for part in segment.parts) + + +def create_parts(source): + """Create parts from a source.""" + independent_cycle = itertools.cycle( + [True] + [False] * (PART_INDEPENDENT_PERIOD - 1) + ) + return [ + Part( + duration=TEST_PART_DURATION, + has_keyframe=next(independent_cycle), + data=bytes(source[i * BYTERANGE_LENGTH : (i + 1) * BYTERANGE_LENGTH]), + ) + for i in range(NUM_PART_SEGMENTS) + ] + + +def http_range_from_part(part): + """Return dummy byterange (length, start) given part number.""" + return BYTERANGE_LENGTH, part * BYTERANGE_LENGTH + + +def make_segment_with_parts( + segment, num_parts, independent_period, discontinuity=False +): + """Create a playlist response for a segment including part segments.""" + response = [] + for i in range(num_parts): + response.append( + f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + ) + if discontinuity: + response.append("#EXT-X-DISCONTINUITY") + response.extend( + [ + "#EXT-X-PROGRAM-DATE-TIME:" + + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + + "Z", + f"#EXTINF:{SEGMENT_DURATION:.3f},", + f"./segment/{segment}.m4s", + ] + ) + return "\n".join(response) + + +def make_hint(segment, part): + """Create a playlist response for the preload hint.""" + return f'#EXT-X-PRELOAD-HINT:TYPE=PART,URI="./segment/{segment}.{part}.m4s"' + + +async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): + """ + Test hls stream. + + Purposefully not mocking anything here to test full + integration with the stream component. + """ + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream_worker_sync.pause() + + # Setup demo HLS track + source = generate_h264_video(duration=SEGMENT_DURATION + 1) + stream = create_stream(hass, source, {}) + + # Request stream + stream.add_provider(HLS_PROVIDER) + stream.start() + + hls_client = await hls_stream(stream) + + # Fetch playlist + master_playlist_response = await hls_client.get() + assert master_playlist_response.status == 200 + + # Fetch init + master_playlist = await master_playlist_response.text() + init_response = await hls_client.get("/init.mp4") + assert init_response.status == 200 + + # Fetch playlist + playlist_url = "/" + master_playlist.splitlines()[-1] + playlist_response = await hls_client.get(playlist_url) + assert playlist_response.status == 200 + + # Fetch segments + playlist = await playlist_response.text() + segment_re = re.compile(r"^(?P./segment/\d+\.m4s)") + for line in playlist.splitlines(): + match = segment_re.match(line) + if match: + segment_url = "/" + match.group("segment_url") + segment_response = await hls_client.get(segment_url) + assert segment_response.status == 200 + + def check_part_is_moof_mdat(data: bytes): + if len(data) < 8 or data[4:8] != b"moof": + return False + moof_length = int.from_bytes(data[0:4], byteorder="big") + if ( + len(data) < moof_length + 8 + or data[moof_length + 4 : moof_length + 8] != b"mdat" + ): + return False + mdat_length = int.from_bytes( + data[moof_length : moof_length + 4], byteorder="big" + ) + if mdat_length + moof_length != len(data): + return False + return True + + # Fetch all completed part segments + part_re = re.compile( + r'#EXT-X-PART:DURATION=[0-9].[0-9]{5,5},URI="(?P.+?)",BYTERANGE="(?P[0-9]+?)@(?P[0-9]+?)"(,INDEPENDENT=YES)?' + ) + for line in playlist.splitlines(): + match = part_re.match(line) + if match: + part_segment_url = "/" + match.group("part_url") + byterange_end = ( + int(match.group("byterange_length")) + + int(match.group("byterange_start")) + - 1 + ) + part_segment_response = await hls_client.get( + part_segment_url, + headers={ + "Range": f'bytes={match.group("byterange_start")}-{byterange_end}' + }, + ) + assert part_segment_response.status == 206 + assert check_part_is_moof_mdat(await part_segment_response.read()) + + stream_worker_sync.resume() + + # Stop stream, if it hasn't quit already + stream.stop() + + # Ensure playlist not accessible after stream ends + fail_response = await hls_client.get() + assert fail_response.status == HTTP_NOT_FOUND + + +async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync): + """Test rendering the hls playlist with 1 and 2 output segments.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + hls = stream.add_provider(HLS_PROVIDER) + + # Add 2 complete segments to output + for sequence in range(2): + segment = create_segment(sequence=sequence) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + await hass.async_block_till_done() + + hls_client = await hls_stream(stream) + + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist( + sequence=0, + segments=[ + make_segment_with_parts(i, len(segment.parts), PART_INDEPENDENT_PERIOD) + for i in range(2) + ], + hint=make_hint(2, 0), + part_target_duration=hls.stream_settings.part_target_duration, + ) + + # add one more segment + segment = create_segment(sequence=2) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + await hass.async_block_till_done() + resp = await hls_client.get("/playlist.m3u8") + assert resp.status == 200 + assert await resp.text() == make_playlist( + sequence=0, + segments=[ + make_segment_with_parts(i, len(segment.parts), PART_INDEPENDENT_PERIOD) + for i in range(3) + ], + hint=make_hint(3, 0), + part_target_duration=hls.stream_settings.part_target_duration, + ) + + stream_worker_sync.resume() + stream.stop() + + +async def test_ll_hls_msn(hass, hls_stream, stream_worker_sync, hls_sync): + """Test that requests using _HLS_msn get held and returned or rejected.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Create 4 requests for sequences 0 through 3 + # 0 and 1 should hold then go through and 2 and 3 should fail immediately. + + hls_sync.reset_request_pool(4) + msn_requests = asyncio.gather( + *(hls_client.get(f"/playlist.m3u8?_HLS_msn={i}") for i in range(4)) + ) + + for sequence in range(3): + await hls_sync.wait_for_handler() + segment = Segment(sequence=sequence, duration=SEGMENT_DURATION) + hls.put(segment) + + msn_responses = await msn_requests + + assert msn_responses[0].status == 200 + assert msn_responses[1].status == 200 + assert msn_responses[2].status == 400 + assert msn_responses[3].status == 400 + + # Sequence number is now 2. Create six more requests for sequences 0 through 5. + # Calls for msn 0 through 4 should work, 5 should fail. + + hls_sync.reset_request_pool(6) + msn_requests = asyncio.gather( + *(hls_client.get(f"/playlist.m3u8?_HLS_msn={i}") for i in range(6)) + ) + for sequence in range(3, 6): + await hls_sync.wait_for_handler() + segment = Segment(sequence=sequence, duration=SEGMENT_DURATION) + hls.put(segment) + + msn_responses = await msn_requests + assert msn_responses[0].status == 200 + assert msn_responses[1].status == 200 + assert msn_responses[2].status == 200 + assert msn_responses[3].status == 200 + assert msn_responses[4].status == 200 + assert msn_responses[5].status == 400 + + stream_worker_sync.resume() + + +async def test_ll_hls_playlist_bad_msn_part(hass, hls_stream, stream_worker_sync): + """Test some playlist requests with invalid _HLS_msn/_HLS_part.""" + + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # If the Playlist URI contains an _HLS_part directive but no _HLS_msn + # directive, the Server MUST return Bad Request, such as HTTP 400. + + assert (await hls_client.get("/playlist.m3u8?_HLS_part=1")).status == 400 + + # Seed hls with 1 complete segment and 1 in process segment + segment = create_segment(sequence=0) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + segment = create_segment(sequence=1) + hls.put(segment) + remaining_parts = create_parts(SEQUENCE_BYTES) + num_completed_parts = len(remaining_parts) // 2 + for part in remaining_parts[:num_completed_parts]: + segment.async_add_part(part, 0) + + # If the _HLS_msn is greater than the Media Sequence Number of the last + # Media Segment in the current Playlist plus two, or if the _HLS_part + # exceeds the last Partial Segment in the current Playlist by the + # Advance Part Limit, then the server SHOULD immediately return Bad + # Request, such as HTTP 400. The Advance Part Limit is three divided + # by the Part Target Duration if the Part Target Duration is less than + # one second, or three otherwise. + + # Current sequence number is 1 and part number is num_completed_parts-1 + # The following two tests should fail immediately: + # - request with a _HLS_msn of 4 + # - request with a _HLS_msn of 1 and a _HLS_part of num_completed_parts-1+advance_part_limit + assert (await hls_client.get("/playlist.m3u8?_HLS_msn=4")).status == 400 + assert ( + await hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={num_completed_parts-1+hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" + ) + ).status == 400 + stream_worker_sync.resume() + + +async def test_ll_hls_playlist_rollover_part( + hass, hls_stream, stream_worker_sync, hls_sync +): + """Test playlist request rollover.""" + + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Seed hls with 1 complete segment and 1 in process segment + for sequence in range(2): + segment = create_segment(sequence=sequence) + hls.put(segment) + + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + await hass.async_block_till_done() + + hls_sync.reset_request_pool(4) + segment = hls.get_segment(1) + # the first request corresponds to the last part of segment 1 + # the remaining requests correspond to part 0 of segment 2 + requests = asyncio.gather( + *( + [ + hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)-1}" + ), + hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)}" + ), + hls_client.get( + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)+1}" + ), + hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), + ] + ) + ) + + await hls_sync.wait_for_handler() + + segment = create_segment(sequence=2) + hls.put(segment) + await hass.async_block_till_done() + + remaining_parts = create_parts(SEQUENCE_BYTES) + segment.async_add_part(remaining_parts.pop(0), 0) + hls.part_put() + + await hls_sync.wait_for_handler() + + different_response, *same_responses = await requests + + assert different_response.status == 200 + assert all(response.status == 200 for response in same_responses) + different_playlist = await different_response.read() + same_playlists = [await response.read() for response in same_responses] + assert different_playlist != same_playlists[0] + assert all(playlist == same_playlists[0] for playlist in same_playlists[1:]) + + stream_worker_sync.resume() + + +async def test_ll_hls_playlist_msn_part(hass, hls_stream, stream_worker_sync, hls_sync): + """Test that requests using _HLS_msn and _HLS_part get held and returned.""" + + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Seed hls with 1 complete segment and 1 in process segment + segment = create_segment(sequence=0) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + segment = create_segment(sequence=1) + hls.put(segment) + remaining_parts = create_parts(SEQUENCE_BYTES) + num_completed_parts = len(remaining_parts) // 2 + for part in remaining_parts[:num_completed_parts]: + segment.async_add_part(part, 0) + del remaining_parts[:num_completed_parts] + + # Make requests for all the part segments up to n+ADVANCE_PART_LIMIT + hls_sync.reset_request_pool( + num_completed_parts + + int(-(-hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit // 1)) + ) + msn_requests = asyncio.gather( + *( + hls_client.get(f"/playlist.m3u8?_HLS_msn=1&_HLS_part={i}") + for i in range( + num_completed_parts + + int(-(-hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit // 1)) + ) + ) + ) + + while remaining_parts: + await hls_sync.wait_for_handler() + segment.async_add_part(remaining_parts.pop(0), 0) + hls.part_put() + + msn_responses = await msn_requests + + # All the responses should succeed except the last one which fails + assert all(response.status == 200 for response in msn_responses[:-1]) + assert msn_responses[-1].status == 400 + + stream_worker_sync.resume() + + +async def test_get_part_segments(hass, hls_stream, stream_worker_sync, hls_sync): + """Test requests for part segments and hinted parts.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) + + stream = create_stream(hass, STREAM_SOURCE, {}) + stream_worker_sync.pause() + + hls = stream.add_provider(HLS_PROVIDER) + + hls_client = await hls_stream(stream) + + # Seed hls with 1 complete segment and 1 in process segment + segment = create_segment(sequence=0) + hls.put(segment) + for part in create_parts(SEQUENCE_BYTES): + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + + segment = create_segment(sequence=1) + hls.put(segment) + remaining_parts = create_parts(SEQUENCE_BYTES) + num_completed_parts = len(remaining_parts) // 2 + for _ in range(num_completed_parts): + segment.async_add_part(remaining_parts.pop(0), 0) + + # Make requests for all the existing part segments + # These should succeed + requests = asyncio.gather( + *( + hls_client.get(f"/segment/1.{part}.m4s") + for part in range(num_completed_parts) + ) + ) + responses = await requests + assert all(response.status == 200 for response in responses) + assert all( + [ + await responses[i].read() == segment.parts[i].data + for i in range(len(responses)) + ] + ) + + # Request for next segment which has not yet been hinted (we will only hint + # for this segment after segment 1 is complete). + # This should fail, but it will hold for one more part_put before failing. + hls_sync.reset_request_pool(1) + request = asyncio.create_task(hls_client.get("/segment/2.0.m4s")) + await hls_sync.wait_for_handler() + hls.part_put() + response = await request + assert response.status == 404 + + # Put the remaining parts and complete the segment + while remaining_parts: + await hls_sync.wait_for_handler() + # Put one more part segment + segment.async_add_part(remaining_parts.pop(0), 0) + hls.part_put() + complete_segment(segment) + + # Now the hint should have moved to segment 2 + # The request for segment 2 which failed before should work now + hls_sync.reset_request_pool(1) + request = asyncio.create_task(hls_client.get("/segment/2.0.m4s")) + # Put an entire segment and its parts. + segment = create_segment(sequence=2) + hls.put(segment) + remaining_parts = create_parts(ALT_SEQUENCE_BYTES) + for part in remaining_parts: + await hls_sync.wait_for_handler() + segment.async_add_part(part, 0) + hls.part_put() + complete_segment(segment) + # Check the response + response = await request + assert response.status == 200 + assert ( + await response.read() + == ALT_SEQUENCE_BYTES[: len(hls.get_segment(2).parts[0].data)] + ) + + stream_worker_sync.resume() diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 31661db3886..ba35b5a4b72 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.stream import create_stream from homeassistant.components.stream.const import HLS_PROVIDER, RECORDER_PROVIDER -from homeassistant.components.stream.core import Part, Segment +from homeassistant.components.stream.core import Part from homeassistant.components.stream.fmp4utils import find_box from homeassistant.components.stream.recorder import recorder_save_worker from homeassistant.exceptions import HomeAssistantError @@ -17,7 +17,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import generate_h264_video, remux_with_audio +from tests.components.stream.common import ( + DefaultSegment as Segment, + generate_h264_video, + remux_with_audio, +) MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever @@ -126,10 +130,9 @@ def add_parts_to_segment(segment, source): Part( duration=None, has_keyframe=None, - http_range_start=None, data=source.getbuffer()[moof_locs[i] : moof_locs[i + 1]], ) - for i in range(1, len(moof_locs) - 1) + for i in range(len(moof_locs) - 1) ] @@ -219,7 +222,7 @@ async def test_record_stream_audio( stream_worker_sync.resume() result = av.open( - BytesIO(last_segment.init + last_segment.get_bytes_without_init()), + BytesIO(last_segment.init + last_segment.get_data()), "r", format="mp4", ) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index e62a190d7be..e353f950aea 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -21,18 +21,27 @@ import threading from unittest.mock import patch import av +import pytest from homeassistant.components.stream import Stream, create_stream from homeassistant.components.stream.const import ( + ATTR_SETTINGS, + CONF_LL_HLS, + CONF_PART_DURATION, + CONF_SEGMENT_DURATION, + DOMAIN, HLS_PROVIDER, MAX_MISSING_DTS, PACKETS_TO_WAIT_FOR_AUDIO, - TARGET_SEGMENT_DURATION, + SEGMENT_DURATION_ADJUSTER, + TARGET_SEGMENT_DURATION_NON_LL_HLS, ) +from homeassistant.components.stream.core import StreamSettings from homeassistant.components.stream.worker import SegmentBuffer, stream_worker from homeassistant.setup import async_setup_component from tests.components.stream.common import generate_h264_video +from tests.components.stream.test_ll_hls import TEST_PART_DURATION STREAM_SOURCE = "some-stream-source" # Formats here are arbitrary, not exercised by tests @@ -43,7 +52,8 @@ AUDIO_SAMPLE_RATE = 11025 KEYFRAME_INTERVAL = 1 # in seconds PACKET_DURATION = fractions.Fraction(1, VIDEO_FRAME_RATE) # in seconds SEGMENT_DURATION = ( - math.ceil(TARGET_SEGMENT_DURATION / KEYFRAME_INTERVAL) * KEYFRAME_INTERVAL + math.ceil(TARGET_SEGMENT_DURATION_NON_LL_HLS / KEYFRAME_INTERVAL) + * KEYFRAME_INTERVAL ) # in seconds TEST_SEQUENCE_LENGTH = 5 * VIDEO_FRAME_RATE LONGER_TEST_SEQUENCE_LENGTH = 20 * VIDEO_FRAME_RATE @@ -53,6 +63,21 @@ SEGMENTS_PER_PACKET = PACKET_DURATION / SEGMENT_DURATION TIMEOUT = 15 +@pytest.fixture(autouse=True) +def mock_stream_settings(hass): + """Set the stream settings data in hass before each test.""" + hass.data[DOMAIN] = { + ATTR_SETTINGS: StreamSettings( + ll_hls=False, + min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS, + hls_advance_part_limit=3, + hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS, + ) + } + + class FakeAvInputStream: """A fake pyav Stream.""" @@ -235,7 +260,7 @@ async def async_decode_stream(hass, packets, py_av=None): "homeassistant.components.stream.core.StreamOutput.put", side_effect=py_av.capture_buffer.capture_output_segment, ): - segment_buffer = SegmentBuffer(stream.outputs) + segment_buffer = SegmentBuffer(hass, stream.outputs) stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) await hass.async_block_till_done() @@ -248,7 +273,7 @@ async def test_stream_open_fails(hass): stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open: av_open.side_effect = av.error.InvalidDataError(-2, "error") - segment_buffer = SegmentBuffer(stream.outputs) + segment_buffer = SegmentBuffer(hass, stream.outputs) stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) await hass.async_block_till_done() av_open.assert_called_once() @@ -638,7 +663,7 @@ async def test_worker_log(hass, caplog): stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open: av_open.side_effect = av.error.InvalidDataError(-2, "error") - segment_buffer = SegmentBuffer(stream.outputs) + segment_buffer = SegmentBuffer(hass, stream.outputs) stream_worker( "https://abcd:efgh@foo.bar", {}, segment_buffer, threading.Event() ) @@ -649,7 +674,17 @@ async def test_worker_log(hass, caplog): async def test_durations(hass, record_worker_sync): """Test that the duration metadata matches the media.""" - await async_setup_component(hass, "stream", {"stream": {}}) + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + CONF_PART_DURATION: TEST_PART_DURATION, + } + }, + ) source = generate_h264_video() stream = create_stream(hass, source, {}) @@ -678,7 +713,9 @@ async def test_durations(hass, record_worker_sync): # check that the Part durations are consistent with the Segment durations for segment in complete_segments: assert math.isclose( - sum(part.duration for part in segment.parts), segment.duration, abs_tol=1e-6 + sum(part.duration for part in segment.parts), + segment.duration, + abs_tol=1e-6, ) await record_worker_sync.join() @@ -688,7 +725,19 @@ async def test_durations(hass, record_worker_sync): async def test_has_keyframe(hass, record_worker_sync): """Test that the has_keyframe metadata matches the media.""" - await async_setup_component(hass, "stream", {"stream": {}}) + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + # Our test video has keyframes every second. Use smaller parts so we have more + # part boundaries to better test keyframe logic. + CONF_PART_DURATION: 0.25, + } + }, + ) source = generate_h264_video() stream = create_stream(hass, source, {}) @@ -697,10 +746,7 @@ async def test_has_keyframe(hass, record_worker_sync): with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path") - # Our test video has keyframes every second. Use smaller parts so we have more - # part boundaries to better test keyframe logic. - with patch("homeassistant.components.stream.worker.TARGET_PART_DURATION", 0.25): - complete_segments = list(await record_worker_sync.get_segments())[:-1] + complete_segments = list(await record_worker_sync.get_segments())[:-1] assert len(complete_segments) >= 1 # check that the Part has_keyframe metadata matches the keyframes in the media diff --git a/tests/components/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py index 7dda9e23d90..854ac923ead 100644 --- a/tests/components/surepetcare/__init__.py +++ b/tests/components/surepetcare/__init__.py @@ -38,6 +38,7 @@ MOCK_CAT_FLAP = { "locking": {"mode": 0}, "learn_mode": 0, "signal": {"device_rssi": 65, "hub_rssi": 64}, + "online": True, }, } @@ -52,6 +53,7 @@ MOCK_PET_FLAP = { "locking": {"mode": 0}, "learn_mode": 0, "signal": {"device_rssi": 70, "hub_rssi": 65}, + "online": True, }, } diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index cecdaababa9..dd1cd19aa0e 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -19,4 +19,5 @@ async def surepetcare(): client = mock_client_class.return_value client.resources = {} client.call = _mock_call + client.get_token.return_value = "token" yield client diff --git a/tests/components/surepetcare/test_binary_sensor.py b/tests/components/surepetcare/test_binary_sensor.py index cd0445dd6d5..aa78628b355 100644 --- a/tests/components/surepetcare/test_binary_sensor.py +++ b/tests/components/surepetcare/test_binary_sensor.py @@ -7,11 +7,11 @@ from homeassistant.setup import async_setup_component from . import HOUSEHOLD_ID, HUB_ID, MOCK_CONFIG EXPECTED_ENTITY_IDS = { - "binary_sensor.pet_flap_pet_flap_connectivity": f"{HOUSEHOLD_ID}-13576-connectivity", - "binary_sensor.cat_flap_cat_flap_connectivity": f"{HOUSEHOLD_ID}-13579-connectivity", - "binary_sensor.feeder_feeder_connectivity": f"{HOUSEHOLD_ID}-12345-connectivity", - "binary_sensor.pet_pet": f"{HOUSEHOLD_ID}-24680", - "binary_sensor.hub_hub": f"{HOUSEHOLD_ID}-{HUB_ID}", + "binary_sensor.pet_flap_connectivity": f"{HOUSEHOLD_ID}-13576-connectivity", + "binary_sensor.cat_flap_connectivity": f"{HOUSEHOLD_ID}-13579-connectivity", + "binary_sensor.feeder_connectivity": f"{HOUSEHOLD_ID}-12345-connectivity", + "binary_sensor.pet": f"{HOUSEHOLD_ID}-24680", + "binary_sensor.hub": f"{HOUSEHOLD_ID}-{HUB_ID}", } diff --git a/tests/components/surepetcare/test_config_flow.py b/tests/components/surepetcare/test_config_flow.py new file mode 100644 index 00000000000..d52dd025148 --- /dev/null +++ b/tests/components/surepetcare/test_config_flow.py @@ -0,0 +1,300 @@ +"""Test the Sure Petcare config flow.""" +from unittest.mock import NonCallableMagicMock, patch + +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError + +from homeassistant import config_entries, setup +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + +INPUT_DATA = { + "username": "test-username", + "password": "test-password", +} + + +async def test_form(hass: HomeAssistant, surepetcare: NonCallableMagicMock) -> 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( + "homeassistant.components.surepetcare.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", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Sure Petcare" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "token": "token", + } + 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.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareAuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +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( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists( + hass, surepetcare: NonCallableMagicMock +) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="surepetcare", + data={ + "username": "test-username", + "password": "test-password", + }, + unique_id="test-username", + ) + first_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.surepetcare.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "username": "test-username", + "password": "test-password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_reauthentication(hass): + """Test surepetcare reauthentication.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="test-username", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + return_value={"token": "token"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure(hass): + """Test surepetcare reauthentication failure.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareAuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "invalid_auth" + + +async def test_reauthentication_cannot_connect(hass): + """Test surepetcare reauthentication failure.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "cannot_connect" + + +async def test_reauthentication_unknown_failure(hass): + """Test surepetcare reauthentication failure.""" + old_entry = MockConfigEntry( + domain="surepetcare", + data=INPUT_DATA, + unique_id="USERID", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == "form" + assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "unknown" diff --git a/tests/components/surepetcare/test_lock.py b/tests/components/surepetcare/test_lock.py new file mode 100644 index 00000000000..a2c4ebad0b3 --- /dev/null +++ b/tests/components/surepetcare/test_lock.py @@ -0,0 +1,115 @@ +"""The tests for the Sure Petcare lock platform.""" +import pytest +from surepy.exceptions import SurePetcareError + +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import HOUSEHOLD_ID, MOCK_CAT_FLAP, MOCK_CONFIG, MOCK_PET_FLAP + +EXPECTED_ENTITY_IDS = { + "lock.cat_flap_locked_in": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_in", + "lock.cat_flap_locked_out": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_out", + "lock.cat_flap_locked_all": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_all", + "lock.pet_flap_locked_in": f"{HOUSEHOLD_ID}-{MOCK_PET_FLAP['id']}-locked_in", + "lock.pet_flap_locked_out": f"{HOUSEHOLD_ID}-{MOCK_PET_FLAP['id']}-locked_out", + "lock.pet_flap_locked_all": f"{HOUSEHOLD_ID}-{MOCK_PET_FLAP['id']}-locked_all", +} + + +async def test_locks(hass, surepetcare) -> None: + """Test the generation of unique ids.""" + assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + state_entity_ids = hass.states.async_entity_ids() + + for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): + surepetcare.reset_mock() + + assert entity_id in state_entity_ids + state = hass.states.get(entity_id) + assert state + assert state.state == "unlocked" + entity = entity_registry.async_get(entity_id) + assert entity.unique_id == unique_id + + await hass.services.async_call( + "lock", "unlock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "unlocked" + # already unlocked + assert surepetcare.unlock.call_count == 0 + + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "locked" + if "locked_in" in entity_id: + assert surepetcare.lock_in.call_count == 1 + elif "locked_out" in entity_id: + assert surepetcare.lock_out.call_count == 1 + elif "locked_all" in entity_id: + assert surepetcare.lock.call_count == 1 + + # lock again should not trigger another request + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "locked" + if "locked_in" in entity_id: + assert surepetcare.lock_in.call_count == 1 + elif "locked_out" in entity_id: + assert surepetcare.lock_out.call_count == 1 + elif "locked_all" in entity_id: + assert surepetcare.lock.call_count == 1 + + await hass.services.async_call( + "lock", "unlock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "unlocked" + assert surepetcare.unlock.call_count == 1 + + +async def test_lock_failing(hass, surepetcare) -> None: + """Test handling of lock failing.""" + assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + + surepetcare.lock_in.side_effect = SurePetcareError + surepetcare.lock_out.side_effect = SurePetcareError + surepetcare.lock.side_effect = SurePetcareError + + for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): + with pytest.raises(SurePetcareError): + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "unlocked" + + +async def test_unlock_failing(hass, surepetcare) -> None: + """Test handling of unlock failing.""" + assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + + entity_id = list(EXPECTED_ENTITY_IDS.keys())[0] + + await hass.services.async_call( + "lock", "lock", {"entity_id": entity_id}, blocking=True + ) + surepetcare.unlock.side_effect = SurePetcareError + + with pytest.raises(SurePetcareError): + await hass.services.async_call( + "lock", "unlock", {"entity_id": entity_id}, blocking=True + ) + state = hass.states.get(entity_id) + assert state.state == "locked" diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index cbf69bb97dc..9edc28dc6dc 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -6,9 +6,9 @@ from homeassistant.setup import async_setup_component from . import HOUSEHOLD_ID, MOCK_CONFIG EXPECTED_ENTITY_IDS = { - "sensor.pet_flap_pet_flap_battery_level": f"{HOUSEHOLD_ID}-13576-battery", - "sensor.cat_flap_cat_flap_battery_level": f"{HOUSEHOLD_ID}-13579-battery", - "sensor.feeder_feeder_battery_level": f"{HOUSEHOLD_ID}-12345-battery", + "sensor.pet_flap_battery_level": f"{HOUSEHOLD_ID}-13576-battery", + "sensor.cat_flap_battery_level": f"{HOUSEHOLD_ID}-13579-battery", + "sensor.feeder_battery_level": f"{HOUSEHOLD_ID}-12345-battery", } diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py new file mode 100644 index 00000000000..5d01a8d0d68 --- /dev/null +++ b/tests/components/switchbot/__init__.py @@ -0,0 +1,70 @@ +"""Tests for the switchbot integration.""" +from unittest.mock import patch + +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DOMAIN = "switchbot" + +ENTRY_CONFIG = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:99:99:99", +} + +USER_INPUT = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:99:99:99", +} + +USER_INPUT_CURTAIN = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:90:90:90", +} + +USER_INPUT_UNSUPPORTED_DEVICE = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "test", +} + +USER_INPUT_INVALID = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "invalid-mac", +} + +YAML_CONFIG = { + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_MAC: "e7:89:43:99:99:99", + CONF_SENSOR_TYPE: "bot", +} + + +def _patch_async_setup_entry(return_value=True): + return patch( + "homeassistant.components.switchbot.async_setup_entry", + return_value=return_value, + ) + + +async def init_integration( + hass: HomeAssistant, + *, + data: dict = ENTRY_CONFIG, + skip_entry_setup: bool = False, +) -> MockConfigEntry: + """Set up the Switchbot integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py new file mode 100644 index 00000000000..52e5fd4fa15 --- /dev/null +++ b/tests/components/switchbot/conftest.py @@ -0,0 +1,117 @@ +"""Define fixtures available for all tests.""" +import sys +from unittest.mock import MagicMock, patch + +from pytest import fixture + + +class MocGetSwitchbotDevices: + """Scan for all Switchbot devices and return by type.""" + + def __init__(self, interface=None) -> None: + """Get switchbot devices class constructor.""" + self._interface = interface + self._all_services_data = { + "e78943999999": { + "mac_address": "e7:89:43:99:99:99", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "switchMode": "true", + "isOn": "true", + "battery": 91, + "rssi": -71, + }, + "model": "H", + "modelName": "WoHand", + }, + "e78943909090": { + "mac_address": "e7:89:43:90:90:90", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "calibration": True, + "battery": 74, + "position": 100, + "lightLevel": 2, + "rssi": -73, + }, + "model": "c", + "modelName": "WoCurtain", + }, + "ffffff19ffff": { + "mac_address": "ff:ff:ff:19:ff:ff", + "Flags": "06", + "Manufacturer": "5900ffffff19ffff", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + }, + } + self._curtain_all_services_data = { + "mac_address": "e7:89:43:90:90:90", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "calibration": True, + "battery": 74, + "position": 100, + "lightLevel": 2, + "rssi": -73, + }, + "model": "c", + "modelName": "WoCurtain", + } + self._unsupported_device = { + "mac_address": "test", + "Flags": "06", + "Manufacturer": "5900e78943d9fe7c", + "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "data": { + "switchMode": "true", + "isOn": "true", + "battery": 91, + "rssi": -71, + }, + "model": "HoN", + "modelName": "WoOther", + } + + def discover(self, retry=0, scan_timeout=0): + """Mock discover.""" + return self._all_services_data + + def get_device_data(self, mac=None): + """Return data for specific device.""" + if mac == "e7:89:43:99:99:99": + return self._all_services_data + if mac == "test": + return self._unsupported_device + if mac == "e7:89:43:90:90:90": + return self._curtain_all_services_data + + return None + + +class MocNotConnectedError(Exception): + """Mock exception.""" + + +module = type(sys)("switchbot") +module.GetSwitchbotDevices = MocGetSwitchbotDevices +module.NotConnectedError = MocNotConnectedError +sys.modules["switchbot"] = module + + +@fixture +def switchbot_config_flow(hass): + """Mock the bluepy api for easier config flow testing.""" + with patch.object(MocGetSwitchbotDevices, "discover", return_value=True), patch( + "homeassistant.components.switchbot.config_flow.GetSwitchbotDevices" + ) as mock_switchbot: + instance = mock_switchbot.return_value + + instance.discover = MagicMock(return_value=True) + + yield mock_switchbot diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py new file mode 100644 index 00000000000..fad0769a7b8 --- /dev/null +++ b/tests/components/switchbot/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the switchbot config flow.""" + +from homeassistant.components.switchbot.config_flow import NotConnectedError +from homeassistant.components.switchbot.const import ( + CONF_RETRY_COUNT, + CONF_RETRY_TIMEOUT, + CONF_SCAN_TIMEOUT, + CONF_TIME_BETWEEN_UPDATE_COMMAND, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.setup import async_setup_component + +from . import ( + USER_INPUT, + USER_INPUT_CURTAIN, + YAML_CONFIG, + _patch_async_setup_entry, + init_integration, +) + +DOMAIN = "switchbot" + + +async def test_user_form_valid_mac(hass): + """Test the user initiated form with password and valid mac.""" + await async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-name" + assert result["data"] == { + CONF_MAC: "e7:89:43:99:99:99", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "bot", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + # test curtain device creation. + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_CURTAIN, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-name" + assert result["data"] == { + CONF_MAC: "e7:89:43:90:90:90", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "curtain", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + # tests abort if no unconfigured devices are found. + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_unconfigured_devices" + + +async def test_async_step_import(hass): + """Test the config import flow.""" + await async_setup_component(hass, "persistent_notification", {}) + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=YAML_CONFIG + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_MAC: "e7:89:43:99:99:99", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "bot", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_form_exception(hass, switchbot_config_flow): + """Test we handle exception on user form.""" + await async_setup_component(hass, "persistent_notification", {}) + + switchbot_config_flow.side_effect = NotConnectedError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + switchbot_config_flow.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_options_flow(hass): + """Test updating options.""" + with _patch_async_setup_entry() as mock_setup_entry: + entry = await init_integration(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TIME_BETWEEN_UPDATE_COMMAND: 60, + CONF_RETRY_COUNT: 3, + CONF_RETRY_TIMEOUT: 5, + CONF_SCAN_TIMEOUT: 5, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 60 + assert result["data"][CONF_RETRY_COUNT] == 3 + assert result["data"][CONF_RETRY_TIMEOUT] == 5 + assert result["data"][CONF_SCAN_TIMEOUT] == 5 + + assert len(mock_setup_entry.mock_calls) == 1 + + # Test changing of entry options. + + with _patch_async_setup_entry() as mock_setup_entry: + entry = await init_integration(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TIME_BETWEEN_UPDATE_COMMAND: 66, + CONF_RETRY_COUNT: 6, + CONF_RETRY_TIMEOUT: 6, + CONF_SCAN_TIMEOUT: 6, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 66 + assert result["data"][CONF_RETRY_COUNT] == 6 + assert result["data"][CONF_RETRY_TIMEOUT] == 6 + assert result["data"][CONF_SCAN_TIMEOUT] == 6 + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py index 07a2396a0d9..2029e4a8ef3 100644 --- a/tests/components/switcher_kis/test_config_flow.py +++ b/tests/components/switcher_kis/test_config_flow.py @@ -44,10 +44,11 @@ async def test_import(hass): ) async def test_user_setup(hass, mock_bridge): """Test we can finish a config flow.""" - with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.async_block_till_done() assert mock_bridge.is_running is False assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 2 @@ -68,10 +69,11 @@ async def test_user_setup(hass, mock_bridge): 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"): + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.async_block_till_done() assert mock_bridge.is_running is False assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 0 diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 70e8a80ad0f..61bc2992f52 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -3,6 +3,8 @@ import re from unittest.mock import patch +from pysyncthru import SyncThruAPINotSupported + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import ssdp from homeassistant.components.syncthru.config_flow import SyncThru @@ -71,7 +73,7 @@ async def test_already_configured_by_url(hass, aioclient_mock): async def test_syncthru_not_supported(hass): """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", side_effect=ValueError): + with patch.object(SyncThru, "update", side_effect=SyncThruAPINotSupported): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index cf043c2ce5f..dec720cfd72 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -257,7 +257,7 @@ async def test_user_vdsm(hass: HomeAssistant, service_vdsm: MagicMock): async def test_reauth(hass: HomeAssistant, service: MagicMock): """Test reauthentication.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: HOST, @@ -265,7 +265,8 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): CONF_PASSWORD: f"{PASSWORD}_invalid", }, unique_id=SERIAL, - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) with patch( "homeassistant.config_entries.ConfigEntries.async_reload", @@ -276,27 +277,21 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): DOMAIN, context={ "source": SOURCE_REAUTH, - "data": { - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - }, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data={ + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" + assert result["step_id"] == "reauth_confirm" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": SOURCE_REAUTH, - "data": { - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - }, - }, - data={ + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, }, @@ -305,22 +300,29 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): assert result["reason"] == "reauth_successful" -async def test_abort_if_already_setup(hass: HomeAssistant, service: MagicMock): - """Test we abort if the account is already setup.""" +async def test_reconfig_user(hass: HomeAssistant, service: MagicMock): + """Test re-configuration of already existing entry by user.""" MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_HOST: "wrong_host", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, unique_id=SERIAL, ).add_to_hass(hass) - # Should fail, same HOST:PORT (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reconfigure_successful" async def test_login_failed(hass: HomeAssistant, service: MagicMock): @@ -379,33 +381,6 @@ async def test_missing_data_after_login(hass: HomeAssistant, service_failed: Mag assert result["errors"] == {"base": "missing_data"} -async def test_form_ssdp_already_configured(hass: HomeAssistant, service: MagicMock): - """Test ssdp abort when the serial number is already configured.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - - MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_MAC: MACS, - }, - unique_id=SERIAL, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - - async def test_form_ssdp(hass: HomeAssistant, service: MagicMock): """Test we can setup from ssdp.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -442,6 +417,62 @@ async def test_form_ssdp(hass: HomeAssistant, service: MagicMock): assert result["data"].get(CONF_VOLUMES) is None +async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock): + """Test re-configuration of already existing entry by ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "wrong_host", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS, + }, + unique_id=SERIAL, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock): + """Test abort of already existing entry by ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.5", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS, + }, + unique_id=SERIAL, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + async def test_options_flow(hass: HomeAssistant, service: MagicMock): """Test config flow options.""" config_entry = MockConfigEntry( diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index ce1dd92942d..a5e23b6021a 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,6 +20,7 @@ async def async_init_integration( me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" zones_fixture = "tado/zones.json" + zone_states_fixture = "tado/zone_states.json" # WR1 Device device_wr1_fixture = "tado/device_wr1.json" @@ -80,6 +81,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones", text=load_fixture(zones_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/zoneStates", + text=load_fixture(zone_states_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/zones/5/capabilities", text=load_fixture(zone_5_capabilities_fixture), diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index 8ef4f7df919..aba448bcbe5 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -1,15 +1,16 @@ -"""The tests for MQTT device triggers.""" +"""The tests for Tasmota device triggers.""" import copy import json -from unittest.mock import patch +from unittest.mock import Mock, patch from hatasmota.switch import TasmotaSwitchTriggerConfig import pytest import homeassistant.components.automation as automation +from homeassistant.components.tasmota import _LOGGER from homeassistant.components.tasmota.const import DEFAULT_PREFIX, DOMAIN -from homeassistant.components.tasmota.device_trigger import async_attach_trigger from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component from .test_common import DEFAULT_CONFIG @@ -812,18 +813,22 @@ async def test_attach_remove(hass, device_reg, mqtt_mock, setup_tasmota): def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. @@ -869,18 +874,22 @@ async def test_attach_remove_late(hass, device_reg, mqtt_mock, setup_tasmota): def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. @@ -936,18 +945,22 @@ async def test_attach_remove_late2(hass, device_reg, mqtt_mock, setup_tasmota): def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the trigger @@ -979,18 +992,22 @@ async def test_attach_remove_unknown1(hass, device_reg, mqtt_mock, setup_tasmota set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - remove = await async_attach_trigger( + remove = await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, - None, - None, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], + Mock(), + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the trigger @@ -1023,18 +1040,22 @@ async def test_attach_unknown_remove_device_from_registry( set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - await async_attach_trigger( + await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, - None, - None, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], + Mock(), + DOMAIN, + "mock-name", + _LOGGER.log, ) # Remove the device @@ -1063,18 +1084,22 @@ async def test_attach_remove_config_entry(hass, device_reg, mqtt_mock, setup_tas def callback(trigger, context): calls.append(trigger["trigger"]["description"]) - await async_attach_trigger( + await async_initialize_triggers( hass, - { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "discovery_id": "00000049A3BC_switch_1_TOGGLE", - "type": "button_short_press", - "subtype": "switch_1", - }, + [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "00000049A3BC_switch_1_TOGGLE", + "type": "button_short_press", + "subtype": "switch_1", + }, + ], callback, - None, + DOMAIN, + "mock-name", + _LOGGER.log, ) # Fake short press. diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index e2168d0925e..410ce21e4c6 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,4 +1,6 @@ """template conftest.""" +import json + import pytest from homeassistant.setup import async_setup_component @@ -13,8 +15,18 @@ def calls(hass): @pytest.fixture -async def start_ha(hass, count, domain, config, caplog): +def config_addon(): + """Add entra configuration items.""" + return None + + +@pytest.fixture +async def start_ha(hass, count, domain, config_addon, config, caplog): """Do setup of integration.""" + if config_addon: + for key, value in config_addon.items(): + config = config.replace(key, value) + config = json.loads(config) with assert_setup_component(count, domain): assert await async_setup_component( hass, @@ -25,3 +37,9 @@ async def start_ha(hass, count, domain, config, caplog): await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +@pytest.fixture +async def caplog_setup_text(caplog): + """Return setup log of integration.""" + yield caplog.text diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index abb2e7b4765..e7a898efc49 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -1,5 +1,6 @@ """The tests for the Template alarm control panel platform.""" -from homeassistant import setup +import pytest + from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -10,21 +11,80 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) -from tests.common import async_mock_service from tests.components.alarm_control_panel import common +TEMPLATE_NAME = "alarm_control_panel.test_template_panel" +PANEL_NAME = "alarm_control_panel.test" -async def test_template_state_text(hass): + +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + ], +) +async def test_template_state_text(hass, start_ha): """Test the state text of a template.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", + + for set_state in [ + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, + ]: + hass.states.async_set(PANEL_NAME, set_state) + await hass.async_block_till_done() + state = hass.states.get(TEMPLATE_NAME) + assert state.state == set_state + + hass.states.async_set(PANEL_NAME, "invalid_state") + await hass.async_block_till_done() + state = hass.states.get(TEMPLATE_NAME) + assert state.state == "unknown" + + +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", "panels": { "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", "arm_away": { "service": "alarm_control_panel.alarm_arm_away", "entity_id": "alarm_control_panel.test", @@ -49,140 +109,30 @@ async def test_template_state_text(hass): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_HOME) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMED_HOME - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMED_AWAY - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_NIGHT) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMED_NIGHT - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMING) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_ARMING - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_DISARMED) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_DISARMED - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_PENDING) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_PENDING - - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_TRIGGERED) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == STATE_ALARM_TRIGGERED - - hass.states.async_set("alarm_control_panel.test", "invalid_state") - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") - assert state.state == "unknown" - - -async def test_optimistic_states(hass): + ], +) +async def test_optimistic_states(hass, start_ha): """Test the optimistic state.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") + state = hass.states.get(TEMPLATE_NAME) await hass.async_block_till_done() assert state.state == "unknown" - await common.async_alarm_arm_away( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_arm_home( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_HOME - - await common.async_alarm_arm_night( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_NIGHT - - await common.async_alarm_disarm( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_DISARMED + for func, set_state in [ + (common.async_alarm_arm_away, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_arm_home, STATE_ALARM_ARMED_HOME), + (common.async_alarm_arm_night, STATE_ALARM_ARMED_NIGHT), + (common.async_alarm_disarm, STATE_ALARM_DISARMED), + ]: + await func(hass, entity_id=TEMPLATE_NAME) + await hass.async_block_till_done() + assert hass.states.get(TEMPLATE_NAME).state == set_state -async def test_no_action_scripts(hass): - """Test no action scripts per state.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", @@ -193,187 +143,162 @@ async def test_no_action_scripts(hass): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_no_action_scripts(hass, start_ha): + """Test no action scripts per state.""" hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) await hass.async_block_till_done() - await common.async_alarm_arm_away( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_arm_home( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_arm_night( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY - - await common.async_alarm_disarm( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.test_template_panel") - await hass.async_block_till_done() - assert state.state == STATE_ALARM_ARMED_AWAY + for func, set_state in [ + (common.async_alarm_arm_away, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_arm_home, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_arm_night, STATE_ALARM_ARMED_AWAY), + (common.async_alarm_disarm, STATE_ALARM_ARMED_AWAY), + ]: + await func(hass, entity_id=TEMPLATE_NAME) + await hass.async_block_till_done() + assert hass.states.get(TEMPLATE_NAME).state == set_state -async def test_template_syntax_error(hass, caplog): +@pytest.mark.parametrize("count,domain", [(0, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config,msg", + [ + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{% if blah %}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + "invalid template", + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "bad name here": { + "value_template": "disarmed", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + "invalid slug bad name", + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "wibble": {"test_panel": "Invalid"}, + } + }, + "[wibble] is an invalid option", + ), + ( + { + "alarm_control_panel": {"platform": "template"}, + }, + "required key not provided @ data['panels']", + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_away", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_night", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "code_format": "bad_format", + } + }, + } + }, + "value must be one of ['no_code', 'number', 'text']", + ), + ], +) +async def test_template_syntax_error(hass, msg, start_ha, caplog_setup_text): """Test templating syntax error.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{% if blah %}", - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 - assert ("invalid template") in caplog.text + assert (msg) in caplog_setup_text -async def test_invalid_name_does_not_create(hass, caplog): - """Test invalid name.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "bad name here": { - "value_template": "{{ disarmed }}", - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - assert ("invalid slug bad name") in caplog.text - - -async def test_invalid_panel_does_not_create(hass, caplog): - """Test invalid alarm control panel.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "wibble": {"test_panel": "Invalid"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - assert ("[wibble] is an invalid option") in caplog.text - - -async def test_no_panels_does_not_create(hass, caplog): - """Test if there are no panels -> no creation.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - {"alarm_control_panel": {"platform": "template"}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - assert ("required key not provided @ data['panels']") in caplog.text - - -async def test_name(hass): - """Test the accessibility of the name attribute.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", "panels": { "test_template_panel": { "name": "Template Alarm Panel", - "value_template": "{{ disarmed }}", + "value_template": "disarmed", "arm_away": { "service": "alarm_control_panel.alarm_arm_away", "entity_id": "alarm_control_panel.test", @@ -398,211 +323,148 @@ async def test_name(hass): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.test_template_panel") + ], +) +async def test_name(hass, start_ha): + """Test the accessibility of the name attribute.""" + state = hass.states.get(TEMPLATE_NAME) assert state is not None - assert state.attributes.get("friendly_name") == "Template Alarm Panel" -async def test_arm_home_action(hass): +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config,func", + [ + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_home": {"service": "test.automation"}, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + common.async_alarm_arm_home, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_away": {"service": "test.automation"}, + "arm_night": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + }, + }, + common.async_alarm_arm_away, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": {"service": "test.automation"}, + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + common.async_alarm_arm_night, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "{{ states('alarm_control_panel.test') }}", + "arm_home": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "disarm": {"service": "test.automation"}, + "arm_away": { + "service": "alarm_control_panel.alarm_arm_home", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + "arm_night": { + "service": "alarm_control_panel.alarm_disarm", + "entity_id": "alarm_control_panel.test", + "data": {"code": "1234"}, + }, + } + }, + } + }, + common.async_alarm_disarm, + ), + ], +) +async def test_arm_home_action(hass, func, start_ha, calls): """Test arm home action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_away": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_home": {"service": "test.automation"}, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - + await func(hass, entity_id=TEMPLATE_NAME) await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_arm_home( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 + assert len(calls) == 1 -async def test_arm_away_action(hass): - """Test arm away action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_away": {"service": "test.automation"}, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_arm_away( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 - - -async def test_arm_night_action(hass): - """Test arm night action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": {"service": "test.automation"}, - "arm_away": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_arm_night( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 - - -async def test_disarm_action(hass): - """Test disarm action.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "disarm": {"service": "test.automation"}, - "arm_away": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "1234"}, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - service_calls = async_mock_service(hass, "test", "automation") - - await common.async_alarm_disarm( - hass, entity_id="alarm_control_panel.test_template_panel" - ) - await hass.async_block_till_done() - - assert len(service_calls) == 1 - - -async def test_unique_id(hass): - """Test unique_id option only creates one alarm control panel per id.""" - await setup.async_setup_component( - hass, - "alarm_control_panel", +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ { "alarm_control_panel": { "platform": "template", @@ -618,10 +480,82 @@ async def test_unique_id(hass): }, }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one alarm control panel per id.""" assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config,code_format,code_arm_required", + [ + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + } + }, + } + }, + "number", + True, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "code_format": "text", + } + }, + } + }, + "text", + True, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "code_format": "no_code", + "code_arm_required": False, + } + }, + } + }, + None, + False, + ), + ( + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_panel": { + "value_template": "disarmed", + "code_format": "text", + "code_arm_required": False, + } + }, + } + }, + "text", + False, + ), + ], +) +async def test_code_config(hass, code_format, code_arm_required, start_ha): + """Test configuration options related to alarm code.""" + state = hass.states.get(TEMPLATE_NAME) + assert state.attributes.get("code_format") == code_format + assert state.attributes.get("code_arm_required") == code_arm_required diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index ccadef5aa96..98d76776242 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -3,6 +3,8 @@ from datetime import timedelta import logging from unittest.mock import patch +import pytest + from homeassistant import setup from homeassistant.components import binary_sensor from homeassistant.const import ( @@ -18,55 +20,43 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed +ON = "on" +OFF = "off" -async def test_setup_legacy(hass): - """Test the setup.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ True }}", - "device_class": "motion", - } + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "{{ True }}", + "device_class": "motion", + } + }, }, - } - } - assert await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() + }, + ], +) +async def test_setup_legacy(hass, start_ha): + """Test the setup.""" state = hass.states.get("binary_sensor.test") assert state is not None assert state.name == "virtual thingy" - assert state.state == "on" + assert state.state == ON assert state.attributes["device_class"] == "motion" -async def test_setup_no_sensors(hass): - """Test setup with no sensors.""" - assert await setup.async_setup_component( - hass, binary_sensor.DOMAIN, {"binary_sensor": {"platform": "template"}} - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 - - -async def test_setup_invalid_device(hass): - """Test the setup with invalid devices.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(0, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + {"binary_sensor": {"platform": "template"}}, {"binary_sensor": {"platform": "template", "sensors": {"foo bar": {}}}}, - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 - - -async def test_setup_invalid_device_class(hass): - """Test setup with invalid sensor class.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, { "binary_sensor": { "platform": "template", @@ -78,32 +68,23 @@ async def test_setup_invalid_device_class(hass): }, } }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 - - -async def test_setup_invalid_missing_template(hass): - """Test setup with invalid and missing template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, { "binary_sensor": { "platform": "template", "sensors": {"test": {"device_class": "motion"}}, } }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 + ], +) +async def test_setup_invalid_sensors(hass, count, start_ha): + """Test setup with no sensors.""" + assert len(hass.states.async_entity_ids()) == count -async def test_icon_template(hass): - """Test icon template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -115,16 +96,14 @@ async def test_icon_template(hass): "'Works' %}" "mdi:check" "{% endif %}", - } + }, }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_icon_template(hass, start_ha): + """Test icon template.""" state = hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("icon") == "" @@ -134,11 +113,10 @@ async def test_icon_template(hass): assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass): - """Test entity_picture template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -150,16 +128,14 @@ async def test_entity_picture_template(hass): "'Works' %}" "/local/sensor.png" "{% endif %}", - } + }, }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_entity_picture_template(hass, start_ha): + """Test entity_picture template.""" state = hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("entity_picture") == "" @@ -169,11 +145,10 @@ async def test_entity_picture_template(hass): assert state.attributes["entity_picture"] == "/local/sensor.png" -async def test_attribute_templates(hass): - """Test attribute_templates template.""" - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -183,16 +158,14 @@ async def test_attribute_templates(hass): "attribute_templates": { "test_attribute": "It {{ states.sensor.test_state.state }}." }, - } + }, }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_attribute_templates(hass, start_ha): + """Test attribute_templates template.""" state = hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("test_attribute") == "It ." hass.states.async_set("sensor.test_state", "Works2") @@ -203,501 +176,228 @@ async def test_attribute_templates(hass): assert state.attributes["test_attribute"] == "It Works." -async def test_match_all(hass): - """Test template that is rerendered on any state lifecycle.""" +@pytest.fixture +async def setup_mock(): + """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." "BinarySensorTemplate._update_state" ) as _update_state: - assert await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, - { - "binary_sensor": { - "platform": "template", - "sensors": { - "match_all_template_sensor": { - "value_template": ( - "{% for state in states %}" - "{% if state.entity_id == 'sensor.humidity' %}" - "{{ state.entity_id }}={{ state.state }}" - "{% endif %}" - "{% endfor %}" - ), - }, + yield _update_state + + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "match_all_template_sensor": { + "value_template": ( + "{% for state in states %}" + "{% if state.entity_id == 'sensor.humidity' %}" + "{{ state.entity_id }}={{ state.state }}" + "{% endif %}" + "{% endfor %}" + ), }, - } + }, + } + }, + ], +) +async def test_match_all(hass, setup_mock, start_ha): + """Test template that is rerendered on any state lifecycle.""" + init_calls = len(setup_mock.mock_calls) + + hass.states.async_set("sensor.any_state", "update") + await hass.async_block_till_done() + assert len(setup_mock.mock_calls) == init_calls + + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + }, + }, }, - ) - - await hass.async_start() - await hass.async_block_till_done() - init_calls = len(_update_state.mock_calls) - - hass.states.async_set("sensor.any_state", "update") - await hass.async_block_till_done() - assert len(_update_state.mock_calls) == init_calls - - -async def test_event(hass): + }, + ], +) +async def test_event(hass, start_ha): """Test the event.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - } + state = hass.states.get("binary_sensor.test") + assert state.state == OFF + + hass.states.async_set("sensor.test_state", ON) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.state == ON + + +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": 5, + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": 5, + }, + }, }, - } - } - assert await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_delay_on(hass): + }, + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', + }, + }, + }, + }, + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, + }, + }, + }, + ], +) +async def test_template_delay_on_off(hass, start_ha): """Test binary sensor template delay on.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == OFF + hass.states.async_set("input_number.delay", 5) + hass.states.async_set("sensor.test_state", ON) await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" + assert hass.states.get("binary_sensor.test_on").state == ON + assert hass.states.get("binary_sensor.test_off").state == ON # check with time changes - hass.states.async_set("sensor.test_state", "off") + hass.states.async_set("sensor.test_state", OFF) await hass.async_block_till_done() + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") + hass.states.async_set("sensor.test_state", ON) await hass.async_block_till_done() + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "off") + hass.states.async_set("sensor.test_state", OFF) await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == ON future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert hass.states.get("binary_sensor.test_on").state == OFF + assert hass.states.get("binary_sensor.test_off").state == OFF -async def test_template_delay_off(hass): - """Test binary sensor template delay off.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - } +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "true", + "device_class": "motion", + "delay_off": 5, + }, + }, }, - } - } - hass.states.async_set("sensor.test_state", "on") - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - # check with time changes - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_with_templated_delay_on(hass): - """Test binary sensor template with template delay on.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - # check with time changes - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - -async def test_template_with_templated_delay_off(hass): - """Test binary sensor template with template delay off.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 6 / 2 }) }}', - } - }, - } - } - hass.states.async_set("sensor.test_state", "on") - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - # check with time changes - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_with_delay_on_based_on_input(hass): - """Test binary sensor template with template delay on based on input number.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - hass.states.async_set("input_number.delay", 3) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - # set input to 4 seconds - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - hass.states.async_set("input_number.delay", 4) - await hass.async_block_till_done() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=2) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - future = dt_util.utcnow() + timedelta(seconds=4) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - -async def test_template_with_delay_off_based_on_input(hass): - """Test binary sensor template with template delay off based on input number.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - hass.states.async_set("input_number.delay", 3) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - # set input to 4 seconds - hass.states.async_set("sensor.test_state", "on") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - hass.states.async_set("input_number.delay", 4) - await hass.async_block_till_done() - - hass.states.async_set("sensor.test_state", "off") - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=2) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "on" - - future = dt_util.utcnow() + timedelta(seconds=4) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state.state == "off" - - -async def test_available_without_availability_template(hass): + }, + ], +) +async def test_available_without_availability_template(hass, start_ha): """Ensure availability is true without an availability_template.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - } - }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -async def test_availability_template(hass): - """Test availability template.""" - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - "availability_template": "{{ is_state('sensor.test_state','on') }}", - } +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "true", + "device_class": "motion", + "delay_off": 5, + "availability_template": "{{ is_state('sensor.test_state','on') }}", + }, + }, }, - } - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + }, + ], +) +async def test_availability_template(hass, start_ha): + """Test availability template.""" hass.states.async_set("sensor.test_state", STATE_OFF) await hass.async_block_till_done() @@ -712,13 +412,10 @@ async def test_availability_template(hass): assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -async def test_invalid_attribute_template(hass, caplog): - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("binary_sensor.test_sensor", "true") - - await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -730,24 +427,22 @@ async def test_invalid_attribute_template(hass, caplog): }, } }, - } + }, }, - ) - await hass.async_block_till_done() + ], +) +async def test_invalid_attribute_template(hass, start_ha, caplog_setup_text): + """Test that errors are logged if rendering template fails.""" + hass.states.async_set("binary_sensor.test_sensor", "true") assert len(hass.states.async_all()) == 2 - await hass.async_start() - await hass.async_block_till_done() - - assert "test_attribute" in caplog.text - assert "TemplateError" in caplog.text + assert ("test_attribute") in caplog_setup_text + assert ("TemplateError") in caplog_setup_text -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - - await setup.async_setup_component( - hass, - binary_sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "binary_sensor": { "platform": "template", @@ -755,22 +450,23 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap "my_sensor": { "value_template": "{{ states.binary_sensor.test_sensor }}", "availability_template": "{{ x - 12 }}", - } + }, }, - } + }, }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("binary_sensor.my_sensor").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert "UndefinedError: 'x' is undefined" in caplog_setup_text async def test_no_update_template_match_all(hass, caplog): """Test that we do not update sensors that match on all.""" - hass.states.async_set("binary_sensor.test_sensor", "true") hass.state = CoreState.not_running @@ -799,29 +495,30 @@ async def test_no_update_template_match_all(hass, caplog): }, ) await hass.async_block_till_done() + hass.states.async_set("binary_sensor.test_sensor", "true") assert len(hass.states.async_all()) == 5 - assert hass.states.get("binary_sensor.all_state").state == "off" - assert hass.states.get("binary_sensor.all_icon").state == "off" - assert hass.states.get("binary_sensor.all_entity_picture").state == "off" - assert hass.states.get("binary_sensor.all_attribute").state == "off" + assert hass.states.get("binary_sensor.all_state").state == OFF + assert hass.states.get("binary_sensor.all_icon").state == OFF + assert hass.states.get("binary_sensor.all_entity_picture").state == OFF + assert hass.states.get("binary_sensor.all_attribute").state == OFF hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.all_state").state == "on" - assert hass.states.get("binary_sensor.all_icon").state == "on" - assert hass.states.get("binary_sensor.all_entity_picture").state == "on" - assert hass.states.get("binary_sensor.all_attribute").state == "on" + assert hass.states.get("binary_sensor.all_state").state == ON + assert hass.states.get("binary_sensor.all_icon").state == ON + assert hass.states.get("binary_sensor.all_entity_picture").state == ON + assert hass.states.get("binary_sensor.all_attribute").state == ON hass.states.async_set("binary_sensor.test_sensor", "false") await hass.async_block_till_done() - assert hass.states.get("binary_sensor.all_state").state == "on" + assert hass.states.get("binary_sensor.all_state").state == ON # Will now process because we have one valid template - assert hass.states.get("binary_sensor.all_icon").state == "off" - assert hass.states.get("binary_sensor.all_entity_picture").state == "off" - assert hass.states.get("binary_sensor.all_attribute").state == "off" + assert hass.states.get("binary_sensor.all_icon").state == OFF + assert hass.states.get("binary_sensor.all_entity_picture").state == OFF + assert hass.states.get("binary_sensor.all_attribute").state == OFF await hass.helpers.entity_component.async_update_entity("binary_sensor.all_state") await hass.helpers.entity_component.async_update_entity("binary_sensor.all_icon") @@ -832,24 +529,23 @@ async def test_no_update_template_match_all(hass, caplog): "binary_sensor.all_attribute" ) - assert hass.states.get("binary_sensor.all_state").state == "on" - assert hass.states.get("binary_sensor.all_icon").state == "off" - assert hass.states.get("binary_sensor.all_entity_picture").state == "off" - assert hass.states.get("binary_sensor.all_attribute").state == "off" + assert hass.states.get("binary_sensor.all_state").state == ON + assert hass.states.get("binary_sensor.all_icon").state == OFF + assert hass.states.get("binary_sensor.all_entity_picture").state == OFF + assert hass.states.get("binary_sensor.all_attribute").state == OFF -async def test_unique_id(hass): - """Test unique_id option only creates one binary sensor per id.""" - await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "unique_id": "group-id", "binary_sensor": { "name": "top-level", "unique_id": "sensor-id", - "state": "on", + "state": ON, }, }, "binary_sensor": { @@ -866,12 +562,10 @@ async def test_unique_id(hass): }, }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one binary sensor per id.""" assert len(hass.states.async_all()) == 2 ent_reg = entity_registry.async_get(hass) @@ -889,28 +583,29 @@ async def test_unique_id(hass): ) -async def test_template_validation_error(hass, caplog): - """Test binary sensor template delay on.""" - caplog.set_level(logging.ERROR) - config = { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "True", - "icon_template": "{{ states.sensor.test_state.state }}", - "device_class": "motion", - "delay_on": 5, +@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "True", + "icon_template": "{{ states.sensor.test_state.state }}", + "device_class": "motion", + "delay_on": 5, + }, }, }, }, - } - await setup.async_setup_component(hass, binary_sensor.DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_template_validation_error(hass, caplog, start_ha): + """Test binary sensor template delay on.""" + caplog.set_level(logging.ERROR) state = hass.states.get("binary_sensor.test") assert state.attributes.get("icon") == "" @@ -931,11 +626,10 @@ async def test_template_validation_error(hass, caplog): assert state.attributes.get("icon") is None -async def test_trigger_entity(hass): - """Test trigger entity works.""" - assert await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(2, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": [ {"invalid": "config"}, @@ -981,24 +675,25 @@ async def test_trigger_entity(hass): }, ], }, - ) - + ], +) +async def test_trigger_entity(hass, start_ha): + """Test trigger entity works.""" await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") assert state is not None - assert state.state == "off" + assert state.state == OFF state = hass.states.get("binary_sensor.bare_minimum") assert state is not None - assert state.state == "off" + assert state.state == OFF context = Context() hass.bus.async_fire("test_event", {"beer": 2}, context=context) await hass.async_block_till_done() state = hass.states.get("binary_sensor.hello_name") - assert state.state == "on" + assert state.state == ON assert state.attributes.get("device_class") == "battery" assert state.attributes.get("icon") == "mdi:pirate" assert state.attributes.get("entity_picture") == "/local/dogs.png" @@ -1017,7 +712,7 @@ async def test_trigger_entity(hass): ) state = hass.states.get("binary_sensor.via_list") - assert state.state == "on" + assert state.state == ON assert state.attributes.get("device_class") == "battery" assert state.attributes.get("icon") == "mdi:pirate" assert state.attributes.get("entity_picture") == "/local/dogs.png" @@ -1029,30 +724,32 @@ async def test_trigger_entity(hass): hass.bus.async_fire("test_event", {"beer": 2, "uno_mas": "si"}) await hass.async_block_till_done() state = hass.states.get("binary_sensor.via_list") - assert state.state == "on" + assert state.state == ON assert state.attributes.get("another") == "si" -async def test_template_with_trigger_templated_delay_on(hass): - """Test binary sensor template with template delay on.""" - config = { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.data.beer == 2 }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', + }, }, - } - } - await setup.async_setup_component(hass, "template", config) - await hass.async_block_till_done() - await hass.async_start() - + }, + ], +) +async def test_template_with_trigger_templated_delay_on(hass, start_ha): + """Test binary sensor template with template delay on.""" state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert state.state == OFF context = Context() hass.bus.async_fire("test_event", {"beer": 2}, context=context) @@ -1063,7 +760,7 @@ async def test_template_with_trigger_templated_delay_on(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == "on" + assert state.state == ON # Now wait for the auto-off future = dt_util.utcnow() + timedelta(seconds=2) @@ -1071,4 +768,4 @@ async def test_template_with_trigger_templated_delay_on(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == "off" + assert state.state == OFF diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index e2b65abcf25..0f629fdd239 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -23,25 +23,18 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) -from tests.common import assert_setup_component, async_mock_service +from tests.common import assert_setup_component ENTITY_COVER = "cover.test_template_cover" -@pytest.fixture(name="calls") -def calls_fixture(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -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( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config, states", + [ + ( { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -58,74 +51,42 @@ async def test_template_state_text(hass, calls, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - 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 - - 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 - - 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 - - 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.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_CLOSED, STATE_CLOSED, {}, -1, ""), + ("cover.test_state", STATE_OPENING, STATE_OPENING, {}, -1, ""), + ("cover.test_state", STATE_CLOSING, STATE_CLOSING, {}, -1, ""), + ( + "cover.test_state", + "dog", + STATE_CLOSING, + {}, + -1, + "Received invalid cover is_on state: dog", + ), + ("cover.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), + ( + "cover.test_state", + "cat", + STATE_OPEN, + {}, + -1, + "Received invalid cover is_on state: cat", + ), + ("cover.test_state", STATE_CLOSED, STATE_CLOSED, {}, -1, ""), + ( + "cover.test_state", + "bear", + STATE_OPEN, + {}, + -1, + "Received invalid cover is_on state: bear", + ), + ], + ), + ( { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -143,344 +104,256 @@ async def test_template_state_text_combined(hass, calls, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - # Test default state + [ + ("cover.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_CLOSED, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_OPENING, STATE_OPENING, {}, -1, ""), + ("cover.test_state", STATE_CLOSING, STATE_CLOSING, {}, -1, ""), + ("cover.test", STATE_CLOSED, STATE_CLOSING, {"position": 0}, 0, ""), + ("cover.test_state", STATE_OPEN, STATE_CLOSED, {}, -1, ""), + ("cover.test", STATE_CLOSED, STATE_OPEN, {"position": 10}, 10, ""), + ( + "cover.test_state", + "dog", + STATE_OPEN, + {}, + -1, + "Received invalid cover is_on state: dog", + ), + ], + ), + ], +) +async def test_template_state_text(hass, states, start_ha, caplog): + """Test the state text of a template.""" 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 + for entity, set_state, test_state, attr, pos, text in states: + hass.states.async_set(entity, set_state, attributes=attr) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == test_state + if pos >= 0: + assert state.attributes.get("current_position") == pos + assert text in caplog.text -async def test_template_state_boolean(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_template_state_boolean(hass, start_ha): """Test the value_template attribute.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "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() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN -async def test_template_position(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ states.cover.test.attributes.position }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test", + }, + } + }, + } + }, + ], +) +async def test_template_position(hass, start_ha): """Test the position_template attribute.""" hass.states.async_set("cover.test", STATE_OPEN) - 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 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.async_set("cover.test", STATE_CLOSED) - await hass.async_block_till_done() - - entity = hass.states.get("cover.test") attrs = {} - attrs["position"] = 42 - hass.states.async_set(entity.entity_id, entity.state, attributes=attrs) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_position") == 42.0 - assert state.state == STATE_OPEN - - state = hass.states.async_set("cover.test", STATE_OPEN) - await hass.async_block_till_done() - entity = hass.states.get("cover.test") - attrs["position"] = 0.0 - hass.states.async_set(entity.entity_id, entity.state, attributes=attrs) - await hass.async_block_till_done() - - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_position") == 0.0 - assert state.state == STATE_CLOSED + for set_state, pos, test_state in [ + (STATE_CLOSED, 42, STATE_OPEN), + (STATE_OPEN, 0.0, STATE_CLOSED), + ]: + state = hass.states.async_set("cover.test", set_state) + await hass.async_block_till_done() + entity = hass.states.get("cover.test") + attrs["position"] = pos + hass.states.async_set(entity.entity_id, entity.state, attributes=attrs) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.attributes.get("current_position") == pos + assert state.state == test_state -async def test_template_tilt(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "tilt_template": "{{ 42 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_template_tilt(hass, start_ha): """Test the tilt_template attribute.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ 42 }}", - "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() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") == 42.0 -async def test_template_out_of_bounds(hass, calls): - """Test template out-of-bounds condition.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ -1 }}", - "tilt_template": "{{ 110 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - } +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ -1 }}", + "tilt_template": "{{ 110 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ on }}", + "tilt_template": "{% if states.cover.test_state.state %}" + "on" + "{% else %}" + "off" + "{% endif %}", + "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() - + }, + } + }, + ], +) +async def test_template_out_of_bounds(hass, start_ha): + """Test template out-of-bounds condition.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") is None assert state.attributes.get("current_position") is None -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( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(0, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}}, } }, - ) - await hass.async_block_till_done() - + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_template_open_or_position(hass, start_ha, caplog_setup_text): + """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all() == [] - assert "Invalid config for [cover.template]" in caplog.text + assert "Invalid config for [cover.template]" in caplog_setup_text -async def test_template_open_and_close(hass, calls): - """Test that if open_cover is specified, close_cover is too.""" - 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 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - } - }, - } - }, - ) - - 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_non_numeric(hass, calls): - """Test that tilt_template values are numeric.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ on }}", - "tilt_template": "{% if states.cover.test_state.state %}" - "on" - "{% else %}" - "off" - "{% endif %}", - "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() - - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") is None - assert state.attributes.get("current_position") is None - - -async def test_open_action(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 0 }}", + "open_cover": {"service": "test.automation"}, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_open_action(hass, start_ha, calls): """Test the open_cover command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 0 }}", - "open_cover": {"service": "test.automation"}, - "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() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_CLOSED @@ -492,34 +365,30 @@ async def test_open_action(hass, calls): assert len(calls) == 1 -async def test_close_stop_action(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 100 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": {"service": "test.automation"}, + "stop_cover": {"service": "test.automation"}, + } + }, + } + }, + ], +) +async def test_close_stop_action(hass, start_ha, calls): """Test the close-cover and stop_cover commands.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": {"service": "test.automation"}, - "stop_cover": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN @@ -536,14 +405,16 @@ async def test_close_stop_action(hass, calls): assert len(calls) == 2 -async def test_set_position(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, "input_number")]) +@pytest.mark.parametrize( + "config", + [ + {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, + ], +) +async def test_set_position(hass, start_ha, calls): """Test the set_position command.""" with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, - ) assert await setup.async_setup_component( hass, "cover", @@ -612,41 +483,48 @@ async def test_set_position(hass, calls): assert state.attributes.get("current_position") == 25.0 -async def test_set_tilt_position(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 100 }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + "set_cover_tilt_position": {"service": "test.automation"}, + } + }, + } + }, + ], +) +@pytest.mark.parametrize( + "service,attr", + [ + ( + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + ), + (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}), + (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}), + ], +) +async def test_set_tilt_position(hass, service, attr, start_ha, calls): """Test the set_tilt_position command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - await hass.services.async_call( DOMAIN, - SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + service, + attr, blocking=True, ) await hass.async_block_till_done() @@ -654,105 +532,24 @@ async def test_set_tilt_position(hass, calls): assert len(calls) == 1 -async def test_open_tilt_action(hass, calls): - """Test the open_cover_tilt command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - - -async def test_close_tilt_action(hass, calls): - """Test the close_cover_tilt command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - - -async def test_set_position_optimistic(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "set_cover_position": {"service": "test.automation"} + } + }, + } + }, + ], +) +async def test_set_position_optimistic(hass, start_ha, calls): """Test optimistic position mode.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "set_cover_position": {"service": "test.automation"} - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") is None @@ -766,58 +563,40 @@ async def test_set_position_optimistic(hass, calls): state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") == 42.0 - await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_CLOSED - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN - - -async def test_set_tilt_position_optimistic(hass, calls): - """Test the optimistic tilt_position mode.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "set_cover_position": {"service": "test.automation"}, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, + for service, test_state in [ + (SERVICE_CLOSE_COVER, STATE_CLOSED), + (SERVICE_OPEN_COVER, STATE_OPEN), + (SERVICE_TOGGLE, STATE_CLOSED), + (SERVICE_TOGGLE, STATE_OPEN), + ]: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == test_state + +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ 100 }}", + "set_cover_position": {"service": "test.automation"}, + "set_cover_tilt_position": {"service": "test.automation"}, + } + }, + } + }, + ], +) +async def test_set_tilt_position_optimistic(hass, start_ha, calls): + """Test the optimistic tilt_position mode.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") is None @@ -831,68 +610,49 @@ async def test_set_tilt_position_optimistic(hass, calls): state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") == 42.0 - await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 0.0 - - await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 100.0 - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 0.0 - - await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 100.0 - - -async def test_icon_template(hass, calls): - """Test icon template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "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", - }, - "icon_template": "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, + for service, pos in [ + (SERVICE_CLOSE_COVER_TILT, 0.0), + (SERVICE_OPEN_COVER_TILT, 100.0), + (SERVICE_TOGGLE_COVER_TILT, 0.0), + (SERVICE_TOGGLE_COVER_TILT, 100.0), + ]: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.attributes.get("current_tilt_position") == pos - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "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", + }, + "icon_template": "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_icon_template(hass, start_ha): + """Test icon template.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("icon") == "" @@ -904,39 +664,35 @@ async def test_icon_template(hass, calls): assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "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", + }, + "entity_picture_template": "{% if states.cover.test_state.state %}" + "/local/cover.png" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_entity_picture_template(hass, start_ha): """Test icon template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "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", - }, - "entity_picture_template": "{% if states.cover.test_state.state %}" - "/local/cover.png" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("entity_picture") == "" @@ -948,37 +704,33 @@ async def test_entity_picture_template(hass, calls): assert state.attributes["entity_picture"] == "/local/cover.png" -async def test_availability_template(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "open", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + "availability_template": "{{ is_state('availability_state.state','on') }}", + } + }, + } + }, + ], +) +async def test_availability_template(hass, start_ha): """Test availability template.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "open", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "availability_template": "{{ is_state('availability_state.state','on') }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() @@ -990,13 +742,12 @@ async def test_availability_template(hass, calls): assert hass.states.get("cover.test_template_cover").state != STATE_UNAVAILABLE -async def test_availability_without_availability_template(hass, calls): - """Test that component is available if there is no.""" - assert await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -1013,23 +764,20 @@ async def test_availability_without_availability_template(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_availability_without_availability_template(hass, start_ha): + """Test that component is available if there is no.""" state = hass.states.get("cover.test_template_cover") assert state.state != STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - assert await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -1047,93 +795,84 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("cover.test_template_cover") != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert ("UndefinedError: 'x' is undefined") in caplog_setup_text -async def test_device_class(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ states.cover.test_state.state }}", + "device_class": "door", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_device_class(hass, start_ha): """Test device class.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "door", - "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() - state = hass.states.get("cover.test_template_cover") assert state.attributes.get("device_class") == "door" -async def test_invalid_device_class(hass, calls): +@pytest.mark.parametrize("count,domain", [(0, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "value_template": "{{ states.cover.test_state.state }}", + "device_class": "barnacle_bill", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ], +) +async def test_invalid_device_class(hass, start_ha): """Test device class.""" - with assert_setup_component(0, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "barnacle_bill", - "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() - state = hass.states.get("cover.test_template_cover") assert not state -async def test_unique_id(hass): - """Test unique_id option only creates one cover per id.""" - await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "test_template_cover_01": { @@ -1161,27 +900,21 @@ async def test_unique_id(hass): }, }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one cover per id.""" assert len(hass.states.async_all()) == 1 -async def test_state_gets_lowercased(hass): - """Test True/False is lowercased.""" - - hass.states.async_set("binary_sensor.garage_door_sensor", "off") - - await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "garage_door": { @@ -1197,12 +930,14 @@ async def test_state_gets_lowercased(hass): }, }, }, - }, + } }, - ) + ], +) +async def test_state_gets_lowercased(hass, start_ha): + """Test True/False is lowercased.""" - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set("binary_sensor.garage_door_sensor", "off") await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 @@ -1213,24 +948,20 @@ async def test_state_gets_lowercased(hass): assert hass.states.get("cover.garage_door").state == STATE_CLOSED -async def test_self_referencing_icon_with_no_template_is_not_a_loop(hass, caplog): - """Test a self referencing icon with no value template is not a loop.""" - - icon_template_str = """{% if is_state('cover.office', 'open') %} - mdi:window-shutter-open - {% else %} - mdi:window-shutter - {% endif %}""" - - await setup.async_setup_component( - hass, - "cover", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "cover": { + DOMAIN: { "platform": "template", "covers": { "office": { - "icon_template": icon_template_str, + "icon_template": """{% if is_state('cover.office', 'open') %} + mdi:window-shutter-open + {% else %} + mdi:window-shutter + {% endif %}""", "open_cover": { "service": "switch.turn_on", "entity_id": "switch.office_blinds_up", @@ -1247,12 +978,12 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop(hass, caplog }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_icon_with_no_template_is_not_a_loop( + hass, start_ha, caplog +): + """Test a self referencing icon with no value template is not a loop.""" assert len(hass.states.async_all()) == 1 assert "Template loop detected" not in caplog.text diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index b5820cd5c76..82f89be9b0a 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -11,6 +11,7 @@ from homeassistant.components.fan import ( ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, @@ -18,7 +19,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from tests.common import assert_setup_component, async_mock_service +from tests.common import assert_setup_component from tests.components.fan import common _TEST_FAN = "fan.test_fan" @@ -38,229 +39,161 @@ _OSC_INPUT = "input_select.osc" _DIRECTION_INPUT_SELECT = "input_select.direction" -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -# Configuration tests # -async def test_missing_optional_config(hass, calls): - """Test: missing optional template is ok.""" - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, None, None, None, None) - - -async def test_missing_value_template_config(hass, calls): - """Test: missing 'value_template' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_turn_on_config(hass, calls): - """Test: missing 'turn_on' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_turn_off_config(hass, calls): - """Test: missing 'turn_off' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_config(hass, calls): - """Test: missing 'turn_off' will fail.""" - with assert_setup_component(0, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { "platform": "template", "fans": { "test_fan": { "value_template": "{{ 'on' }}", "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, } }, - }, - ) + } + }, + ], +) +async def test_missing_optional_config(hass, start_ha): + """Test: missing optional template is ok.""" + _verify(hass, STATE_ON, None, None, None, None, None) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +@pytest.mark.parametrize("count,domain", [(0, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + }, + } + }, + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "turn_off": {"service": "script.fan_off"}, + } + }, + }, + } + }, + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "turn_on": {"service": "script.fan_on"}, + } + }, + }, + } + }, + { + DOMAIN: { + "platform": "template", + "fans": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "turn_on": {"service": "script.fan_on"}, + } + }, + }, + } + }, + ], +) +async def test_wrong_template_config(hass, start_ha): + """Test: missing 'value_template' will fail.""" assert hass.states.async_all() == [] -# End of configuration tests # - - -# Template tests # -async def test_templates_with_entities(hass, calls): - """Test tempalates with values from other entities.""" - value_template = """ +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "test_fan": { + "value_template": """ {% if is_state('input_boolean.state', 'True') %} {{ 'on' }} {% else %} {{ 'off' }} {% endif %} - """ - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": value_template, - "percentage_template": "{{ states('input_number.percentage') }}", - "speed_template": "{{ states('input_select.speed') }}", - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "speed_count": "3", - "set_percentage": { - "service": "script.fans_set_speed", - "data_template": {"percentage": "{{ percentage }}"}, - }, - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + """, + "percentage_template": "{{ states('input_number.percentage') }}", + "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "speed_count": "3", + "set_percentage": { + "service": "script.fans_set_speed", + "data_template": {"percentage": "{{ percentage }}"}, + }, + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ], +) +async def test_templates_with_entities(hass, start_ha): + """Test tempalates with values from other entities.""" _verify(hass, STATE_OFF, None, 0, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, True) hass.states.async_set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) hass.states.async_set(_OSC_INPUT, "True") - hass.states.async_set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 33) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_LOW, 33, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 100) - await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_HIGH, 100, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, "dog") - await hass.async_block_till_done() - _verify(hass, STATE_ON, None, 0, True, DIRECTION_FORWARD, None) + for set_state, set_value, speed, value in [ + (_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD, SPEED_MEDIUM, 66), + (_PERCENTAGE_INPUT_NUMBER, 33, SPEED_LOW, 33), + (_PERCENTAGE_INPUT_NUMBER, 66, SPEED_MEDIUM, 66), + (_PERCENTAGE_INPUT_NUMBER, 100, SPEED_HIGH, 100), + (_PERCENTAGE_INPUT_NUMBER, "dog", None, 0), + ]: + hass.states.async_set(set_state, set_value) + await hass.async_block_till_done() + _verify(hass, STATE_ON, speed, value, True, DIRECTION_FORWARD, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, False) await hass.async_block_till_done() _verify(hass, STATE_OFF, None, 0, True, DIRECTION_FORWARD, None) -async def test_templates_with_entities_and_invalid_percentage(hass, calls): - """Test templates with values from other entities.""" - hass.states.async_set("sensor.percentage", "0") - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config,entity,tests", + [ + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -272,50 +205,19 @@ async def test_templates_with_entities_and_invalid_percentage(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None) - - hass.states.async_set("sensor.percentage", "33") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) - - hass.states.async_set("sensor.percentage", "invalid") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, 0, None, None, None) - - hass.states.async_set("sensor.percentage", "5000") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, 0, None, None, None) - - hass.states.async_set("sensor.percentage", "100") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - hass.states.async_set("sensor.percentage", "0") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None) - - -async def test_templates_with_entities_and_preset_modes(hass, calls): - """Test templates with values from other entities.""" - hass.states.async_set("sensor.preset_mode", "0") - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + "sensor.percentage", + [ + ("0", 0, SPEED_OFF, None), + ("33", 33, SPEED_LOW, None), + ("invalid", 0, None, None), + ("5000", 0, None, None), + ("100", 100, SPEED_HIGH, None), + ("0", 0, SPEED_OFF, None), + ], + ), + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -328,43 +230,62 @@ async def test_templates_with_entities_and_preset_modes(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, None, None, None, None) - - hass.states.async_set("sensor.preset_mode", "invalid") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, None, None, None, None) - - hass.states.async_set("sensor.preset_mode", "auto") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, "auto", None, None, None, "auto") - - hass.states.async_set("sensor.preset_mode", "smart") - await hass.async_block_till_done() - - _verify(hass, STATE_ON, "smart", None, None, None, "smart") - - hass.states.async_set("sensor.preset_mode", "invalid") - await hass.async_block_till_done() - _verify(hass, STATE_ON, None, None, None, None, None) + "sensor.preset_mode", + [ + ("0", None, None, None), + ("invalid", None, None, None), + ("auto", None, "auto", "auto"), + ("smart", None, "smart", "smart"), + ("invalid", None, None, None), + ], + ), + ], +) +async def test_templates_with_entities2(hass, entity, tests, start_ha): + """Test templates with values from other entities.""" + for set_percentage, test_percentage, speed, test_type in tests: + hass.states.async_set(entity, set_percentage) + await hass.async_block_till_done() + _verify(hass, STATE_ON, speed, test_percentage, None, None, test_type) -async def test_template_with_unavailable_entities(hass, calls): - """Test unavailability with value_template.""" +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "test_fan": { + "availability_template": "{{ is_state('availability_boolean.state', 'on') }}", + "value_template": "{{ 'on' }}", + "speed_template": "{{ 'medium' }}", + "oscillating_template": "{{ 1 == 1 }}", + "direction_template": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ], +) +async def test_availability_template_with_entities(hass, start_ha): + """Test availability tempalates with values from other entities.""" + for state, test_assert in [(STATE_ON, True), (STATE_OFF, False)]: + hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) + await hass.async_block_till_done() + assert (hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE) == test_assert - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config, states", + [ + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -375,23 +296,11 @@ async def test_template_with_unavailable_entities(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get(_TEST_FAN).state == STATE_OFF - - -async def test_template_with_unavailable_parameters(hass, calls): - """Test unavailability of speed, direction and oscillating parameters.""" - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + [STATE_OFF, None, None, None, None], + ), + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -405,67 +314,11 @@ async def test_template_with_unavailable_parameters(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, None, 0, None, None, None) - - -async def test_availability_template_with_entities(hass, calls): - """Test availability tempalates with values from other entities.""" - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + [STATE_ON, None, 0, None, None], + ), + ( { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "availability_template": "{{ is_state('availability_boolean.state', 'on') }}", - "value_template": "{{ 'on' }}", - "speed_template": "{{ 'medium' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - # When template returns true.. - hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_ON) - await hass.async_block_till_done() - - # Device State should not be unavailable - assert hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE - - # When Availability template returns false - hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_OFF) - await hass.async_block_till_done() - - # device state should be unavailable - assert hass.states.get(_TEST_FAN).state == STATE_UNAVAILABLE - - -async def test_templates_with_valid_values(hass, calls): - """Test templates with valid values.""" - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -479,23 +332,11 @@ async def test_templates_with_valid_values(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) - - -async def test_templates_invalid_values(hass, calls): - """Test templates with invalid values.""" - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", + [STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD], + ), + ( { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -509,437 +350,249 @@ async def test_templates_invalid_values(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_OFF, None, 0, None, None, None) + [STATE_OFF, None, 0, None, None], + ), + ], +) +async def test_template_with_unavailable_entities(hass, states, start_ha): + """Test unavailability with value_template.""" + _verify(hass, states[0], states[1], states[2], states[3], states[4], None) -async def test_invalid_availability_template_keeps_component_available(hass, caplog): +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "availability_template": "{{ x - 12 }}", + "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): """Test that an invalid availability keeps the device available.""" - - with assert_setup_component(1, "fan"): - assert await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "availability_template": "{{ x - 12 }}", - "speed_template": "{{ states('input_select.speed') }}", - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("fan.test_fan").state != STATE_UNAVAILABLE - - assert "TemplateError" in caplog.text - assert "x" in caplog.text + assert "TemplateError" in caplog_setup_text + assert "x" in caplog_setup_text -# End of template tests # - - -# Function tests # -async def test_on_off(hass, calls): +async def test_on_off(hass): """Test turn on and turn off.""" await _register_components(hass) - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # verify - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - _verify(hass, STATE_ON, None, 0, None, None, None) - - # Turn off fan - await common.async_turn_off(hass, _TEST_FAN) - - # verify - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF - _verify(hass, STATE_OFF, None, 0, None, None, None) + for func, state in [ + (common.async_turn_on, STATE_ON), + (common.async_turn_off, STATE_OFF), + ]: + await func(hass, _TEST_FAN) + assert hass.states.get(_STATE_INPUT_BOOLEAN).state == state + _verify(hass, state, None, 0, None, None, None) -async def test_on_with_speed(hass, calls): - """Test turn on with speed.""" - await _register_components(hass) - - # Turn on fan with high speed - await common.async_turn_on(hass, _TEST_FAN, SPEED_HIGH) - - # verify - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - -async def test_set_speed(hass, calls): +async def test_set_speed(hass): """Test set valid speed.""" await _register_components(hass, preset_modes=["auto", "smart"]) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to high - await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's speed to medium - await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - # Set fan's speed to off - await common.async_set_speed(hass, _TEST_FAN, SPEED_OFF) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_OFF - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + for cmd, t_state, type, state, value in [ + (SPEED_HIGH, SPEED_HIGH, SPEED_HIGH, STATE_ON, 100), + (SPEED_MEDIUM, SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + (SPEED_OFF, SPEED_OFF, SPEED_OFF, STATE_OFF, 0), + (SPEED_MEDIUM, SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + ("invalid", SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + ]: + await common.async_set_speed(hass, _TEST_FAN, cmd) + assert hass.states.get(_SPEED_INPUT_SELECT).state == t_state + _verify(hass, state, type, value, None, None, None) -async def test_set_percentage(hass, calls): - """Test set valid speed percentage.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's percentage speed to 100 - await common.async_set_percentage(hass, _TEST_FAN, 100) - - # verify - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's percentage speed to 66 - await common.async_set_percentage(hass, _TEST_FAN, 66) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - # Set fan's percentage speed to 0 - await common.async_set_percentage(hass, _TEST_FAN, 0) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 - - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) - - # Set fan's percentage speed to 50 - await common.async_turn_on(hass, _TEST_FAN, percentage=50) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) - - -async def test_increase_decrease_speed(hass, calls): - """Test set valid increase and decrease speed.""" - await _register_components(hass, speed_count=3) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's percentage speed to 100 - await common.async_set_percentage(hass, _TEST_FAN, 100) - - # verify - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's percentage speed to 66 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - # Set fan's percentage speed to 33 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 - - _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) - - # Set fan's percentage speed to 0 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 - - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) - - # Set fan's percentage speed to 33 - await common.async_increase_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 - - _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) - - -async def test_increase_decrease_speed_default_speed_count(hass, calls): - """Test set valid increase and decrease speed.""" - await _register_components( - hass, - ) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's percentage speed to 100 - await common.async_set_percentage(hass, _TEST_FAN, 100) - - # verify - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 - - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's percentage speed to 99 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 99 - - _verify(hass, STATE_ON, SPEED_HIGH, 99, None, None, None) - - # Set fan's percentage speed to 98 - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 98 - - _verify(hass, STATE_ON, SPEED_HIGH, 98, None, None, None) - - for _ in range(32): - await common.async_decrease_speed(hass, _TEST_FAN) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 - - _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) - - -async def test_set_invalid_speed_from_initial_stage(hass, calls): - """Test set invalid speed when fan is in initial state.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to 'invalid' - await common.async_set_speed(hass, _TEST_FAN, "invalid") - - # verify speed is unchanged - assert hass.states.get(_SPEED_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, None, 0, None, None, None) - - -async def test_set_invalid_speed(hass, calls): +async def test_set_invalid_speed(hass): """Test set invalid speed when fan has valid speed.""" await _register_components(hass) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to high - await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH) - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - # Set fan's speed to 'invalid' - await common.async_set_speed(hass, _TEST_FAN, "invalid") - - # verify speed is unchanged - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + for extra in [SPEED_HIGH, "invalid"]: + await common.async_set_speed(hass, _TEST_FAN, extra) + assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) -async def test_custom_speed_list(hass, calls): +async def test_custom_speed_list(hass): """Test set custom speed list.""" await _register_components(hass, ["1", "2", "3"]) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's speed to '1' - await common.async_set_speed(hass, _TEST_FAN, "1") - - # verify - assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", 33, None, None, None) - - # Set fan's speed to 'medium' which is invalid - await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) - - # verify that speed is unchanged - assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", 33, None, None, None) - - -async def test_preset_modes(hass, calls): - """Test preset_modes.""" - await _register_components( - hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] - ) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's preset_mode to "auto" - await common.async_set_preset_mode(hass, _TEST_FAN, "auto") - - # verify - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" - - # Set fan's preset_mode to "smart" - await common.async_set_preset_mode(hass, _TEST_FAN, "smart") - - # Verify fan's preset_mode is "smart" - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" - - # Set fan's preset_mode to "invalid" - await common.async_set_preset_mode(hass, _TEST_FAN, "invalid") - - # Verify fan's preset_mode is still "smart" - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" - - # Set fan's preset_mode to "auto" - await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") - - # verify - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" - - -async def test_set_osc(hass, calls): - """Test set oscillating.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's osc to True - await common.async_oscillate(hass, _TEST_FAN, True) - - # verify - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, 0, True, None, None) - - # Set fan's osc to False - await common.async_oscillate(hass, _TEST_FAN, False) - - # verify - assert hass.states.get(_OSC_INPUT).state == "False" - _verify(hass, STATE_ON, None, 0, False, None, None) - - -async def test_set_invalid_osc_from_initial_state(hass, calls): - """Test set invalid oscillating when fan is in initial state.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's osc to 'invalid' - with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, "invalid") - - # verify - assert hass.states.get(_OSC_INPUT).state == "" - _verify(hass, STATE_ON, None, 0, None, None, None) - - -async def test_set_invalid_osc(hass, calls): - """Test set invalid oscillating when fan has valid osc.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's osc to True - await common.async_oscillate(hass, _TEST_FAN, True) - - # verify - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, 0, True, None, None) - - # Set fan's osc to None - with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, None) - - # verify osc is unchanged - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, 0, True, None, None) - - -async def test_set_direction(hass, calls): - """Test set valid direction.""" - await _register_components(hass) - - # Turn on fan - await common.async_turn_on(hass, _TEST_FAN) - - # Set fan's direction to forward - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) - - # verify - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) - - # Set fan's direction to reverse - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_REVERSE) - - # verify - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_REVERSE - _verify(hass, STATE_ON, None, 0, None, DIRECTION_REVERSE, None) + for extra in ["1", SPEED_MEDIUM]: + await common.async_set_speed(hass, _TEST_FAN, extra) + assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" + _verify(hass, STATE_ON, "1", 33, None, None, None) async def test_set_invalid_direction_from_initial_stage(hass, calls): """Test set invalid direction when fan is in initial state.""" await _register_components(hass) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) - # Set fan's direction to 'invalid' await common.async_set_direction(hass, _TEST_FAN, "invalid") - - # verify direction is unchanged assert hass.states.get(_DIRECTION_INPUT_SELECT).state == "" _verify(hass, STATE_ON, None, 0, None, None, None) -async def test_set_invalid_direction(hass, calls): +async def test_set_osc(hass): + """Test set oscillating.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for state in [True, False]: + await common.async_oscillate(hass, _TEST_FAN, state) + assert hass.states.get(_OSC_INPUT).state == str(state) + _verify(hass, STATE_ON, None, 0, state, None, None) + + +async def test_set_direction(hass): + """Test set valid direction.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for cmd in [DIRECTION_FORWARD, DIRECTION_REVERSE]: + await common.async_set_direction(hass, _TEST_FAN, cmd) + assert hass.states.get(_DIRECTION_INPUT_SELECT).state == cmd + _verify(hass, STATE_ON, None, 0, None, cmd, None) + + +async def test_set_invalid_direction(hass): """Test set invalid direction when fan has valid direction.""" await _register_components(hass) - # Turn on fan await common.async_turn_on(hass, _TEST_FAN) + for cmd in [DIRECTION_FORWARD, "invalid"]: + await common.async_set_direction(hass, _TEST_FAN, cmd) + assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) - # Set fan's direction to forward - await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) - # verify - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) +async def test_on_with_speed(hass): + """Test turn on with speed.""" + await _register_components(hass) - # Set fan's direction to 'invalid' - await common.async_set_direction(hass, _TEST_FAN, "invalid") + await common.async_turn_on(hass, _TEST_FAN, SPEED_HIGH) + assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - # verify direction is unchanged - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) + +async def test_preset_modes(hass): + """Test preset_modes.""" + await _register_components( + hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] + ) + + await common.async_turn_on(hass, _TEST_FAN) + for extra, state in [ + ("auto", "auto"), + ("smart", "smart"), + ("invalid", "smart"), + ]: + await common.async_set_preset_mode(hass, _TEST_FAN, extra) + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state + + await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" + + +async def test_set_percentage(hass): + """Test set valid speed percentage.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for type, state, value in [ + (SPEED_HIGH, STATE_ON, 100), + (SPEED_MEDIUM, STATE_ON, 66), + (SPEED_OFF, STATE_OFF, 0), + ]: + await common.async_set_percentage(hass, _TEST_FAN, value) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + _verify(hass, state, type, value, None, None, None) + + await common.async_turn_on(hass, _TEST_FAN, percentage=50) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 + _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) + + +async def test_increase_decrease_speed(hass): + """Test set valid increase and decrease speed.""" + await _register_components(hass, speed_count=3) + + await common.async_turn_on(hass, _TEST_FAN) + for func, extra, state, type, value in [ + (common.async_set_percentage, 100, STATE_ON, SPEED_HIGH, 100), + (common.async_decrease_speed, None, STATE_ON, SPEED_MEDIUM, 66), + (common.async_decrease_speed, None, STATE_ON, SPEED_LOW, 33), + (common.async_decrease_speed, None, STATE_OFF, SPEED_OFF, 0), + (common.async_increase_speed, None, STATE_ON, SPEED_LOW, 33), + ]: + await func(hass, _TEST_FAN, extra) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + _verify(hass, state, type, value, None, None, None) + + +async def test_increase_decrease_speed_default_speed_count(hass): + """Test set valid increase and decrease speed.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + for func, extra, state, type, value in [ + (common.async_set_percentage, 100, STATE_ON, SPEED_HIGH, 100), + (common.async_decrease_speed, None, STATE_ON, SPEED_HIGH, 99), + (common.async_decrease_speed, None, STATE_ON, SPEED_HIGH, 98), + (common.async_decrease_speed, 31, STATE_ON, SPEED_HIGH, 67), + (common.async_decrease_speed, None, STATE_ON, SPEED_MEDIUM, 66), + ]: + await func(hass, _TEST_FAN, extra) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + _verify(hass, state, type, value, None, None, None) + + +async def test_set_invalid_osc_from_initial_state(hass): + """Test set invalid oscillating when fan is in initial state.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + with pytest.raises(vol.Invalid): + await common.async_oscillate(hass, _TEST_FAN, "invalid") + assert hass.states.get(_OSC_INPUT).state == "" + _verify(hass, STATE_ON, None, 0, None, None, None) + + +async def test_set_invalid_osc(hass): + """Test set invalid oscillating when fan has valid osc.""" + await _register_components(hass) + + await common.async_turn_on(hass, _TEST_FAN) + await common.async_oscillate(hass, _TEST_FAN, True) + assert hass.states.get(_OSC_INPUT).state == "True" + _verify(hass, STATE_ON, None, 0, True, None, None) + + with pytest.raises(vol.Invalid): + await common.async_oscillate(hass, _TEST_FAN, None) + assert hass.states.get(_OSC_INPUT).state == "True" + _verify(hass, STATE_ON, None, 0, True, None, None) def _verify( @@ -1103,13 +756,12 @@ async def _register_components( await hass.async_block_till_done() -async def test_unique_id(hass): - """Test unique_id option only creates one fan per id.""" - await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "fan": { + DOMAIN: { "platform": "template", "fans": { "test_template_fan_01": { @@ -1137,14 +789,12 @@ async def test_unique_id(hass): }, }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one fan per id.""" assert len(hass.states.async_all()) == 1 @@ -1221,13 +871,12 @@ async def test_implemented_percentage(hass, speed_count, percentage_step): assert attributes["percentage_step"] == percentage_step -async def test_implemented_preset_mode(hass): - """Test a fan that implements preset_mode.""" - await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "fan": { + DOMAIN: { "platform": "template", "fans": { "mechanical_ventilation": { @@ -1276,14 +925,12 @@ async def test_implemented_preset_mode(hass): ], }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_implemented_preset_mode(hass, start_ha): + """Test a fan that implements preset_mode.""" assert len(hass.states.async_all()) == 1 state = hass.states.get("fan.mechanical_ventilation") @@ -1291,13 +938,12 @@ async def test_implemented_preset_mode(hass): assert attributes["percentage"] is None -async def test_implemented_speed(hass): - """Test a fan that implements speed.""" - await setup.async_setup_component( - hass, - "fan", +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "fan": { + DOMAIN: { "platform": "template", "fans": { "mechanical_ventilation": { @@ -1346,14 +992,12 @@ async def test_implemented_speed(hass): ], }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_implemented_speed(hass, start_ha): + """Test a fan that implements speed.""" assert len(hass.states.async_all()) == 1 state = hass.states.get("fan.mechanical_ventilation") diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index ddbb165e509..c179123e035 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -3,6 +3,8 @@ from datetime import timedelta from os import path from unittest.mock import patch +import pytest + from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.helpers.reload import SERVICE_RELOAD @@ -12,13 +14,10 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_reloadable(hass): - """Test that we can reload.""" - hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -48,17 +47,17 @@ async def test_reloadable(hass): }, ], }, - ) - await hass.async_block_till_done() - - await hass.async_start() + ], +) +async def test_reloadable(hass, start_ha): + """Test that we can reload.""" + hass.states.async_set("sensor.test_sensor", "mytest") await hass.async_block_till_done() assert hass.states.get("sensor.top_level_state").state == "unknown + 2" assert hass.states.get("binary_sensor.top_level_state").state == "off" hass.bus.async_fire("event_1", {"source": "init"}) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 5 assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("sensor.top_level").state == "init" @@ -66,25 +65,11 @@ async def test_reloadable(hass): assert hass.states.get("sensor.top_level_state").state == "init + 2" assert hass.states.get("binary_sensor.top_level_state").state == "on" - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/sensor_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "sensor_configuration.yaml") assert len(hass.states.async_all()) == 4 hass.bus.async_fire("event_2", {"source": "reload"}) await hass.async_block_till_done() - assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.top_level") is None assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" @@ -92,13 +77,10 @@ async def test_reloadable(hass): assert hass.states.get("sensor.top_level_2").state == "reload" -async def test_reloadable_can_remove(hass): - """Test that we can reload and remove all template sensors.""" - hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -116,43 +98,54 @@ async def test_reloadable_can_remove(hass): }, }, }, - ) + ], +) +async def test_reloadable_can_remove(hass, start_ha): + """Test that we can reload and remove all template sensors.""" + hass.states.async_set("sensor.test_sensor", "mytest") await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - hass.bus.async_fire("event_1", {"source": "init"}) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("sensor.top_level").state == "init" - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/empty_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "empty_configuration.yaml") assert len(hass.states.async_all()) == 1 -async def test_reloadable_stops_on_invalid_config(hass): +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": DOMAIN, + "sensors": { + "state": { + "value_template": "{{ states.sensor.test_sensor.state }}" + }, + }, + } + }, + ], +) +async def test_reloadable_stops_on_invalid_config(hass, start_ha): """Test we stop the reload if configuration.yaml is completely broken.""" hass.states.async_set("sensor.test_sensor", "mytest") + await hass.async_block_till_done() + assert hass.states.get("sensor.state").state == "mytest" + assert len(hass.states.async_all()) == 2 - await async_setup_component( - hass, - "sensor", + await async_yaml_patch_helper(hass, "configuration.yaml.corrupt") + assert hass.states.get("sensor.state").state == "mytest" + assert len(hass.states.async_all()) == 2 + + +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -163,75 +156,16 @@ async def test_reloadable_stops_on_invalid_config(hass): }, } }, - ) - - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.get("sensor.state").state == "mytest" - assert len(hass.states.async_all()) == 2 - - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/configuration.yaml.corrupt", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert hass.states.get("sensor.state").state == "mytest" - assert len(hass.states.async_all()) == 2 - - -async def test_reloadable_handles_partial_valid_config(hass): + ], +) +async def test_reloadable_handles_partial_valid_config(hass, start_ha): """Test we can still setup valid sensors when configuration.yaml has a broken entry.""" hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", - { - "sensor": { - "platform": DOMAIN, - "sensors": { - "state": { - "value_template": "{{ states.sensor.test_sensor.state }}" - }, - }, - } - }, - ) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("sensor.state").state == "mytest" assert len(hass.states.async_all()) == 2 - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/broken_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "broken_configuration.yaml") assert len(hass.states.async_all()) == 3 assert hass.states.get("sensor.state") is None @@ -239,13 +173,10 @@ async def test_reloadable_handles_partial_valid_config(hass): assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 -async def test_reloadable_multiple_platforms(hass): - """Test that we can reload.""" - hass.states.async_set("sensor.test_sensor", "mytest") - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -256,7 +187,11 @@ async def test_reloadable_multiple_platforms(hass): }, } }, - ) + ], +) +async def test_reloadable_multiple_platforms(hass, start_ha): + """Test that we can reload.""" + hass.states.async_set("sensor.test_sensor", "mytest") await async_setup_component( hass, "binary_sensor", @@ -272,43 +207,22 @@ async def test_reloadable_multiple_platforms(hass): }, ) await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("binary_sensor.state").state == "off" - assert len(hass.states.async_all()) == 3 - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/sensor_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + await async_yaml_patch_helper(hass, "sensor_configuration.yaml") assert len(hass.states.async_all()) == 4 - assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 assert hass.states.get("sensor.top_level_2") is not None -async def test_reload_sensors_that_reference_other_template_sensors(hass): - """Test that we can reload sensor that reference other template sensors.""" - - await async_setup_component( - hass, - "sensor", +@pytest.mark.parametrize("count,domain", [(1, "sensor")]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": DOMAIN, @@ -317,22 +231,11 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass): }, } }, - ) - await hass.async_block_till_done() - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/ref_configuration.yaml", - ) - with patch.object(config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - + ], +) +async def test_reload_sensors_that_reference_other_template_sensors(hass, start_ha): + """Test that we can reload sensor that reference other template sensors.""" + await async_yaml_patch_helper(hass, "ref_configuration.yaml") assert len(hass.states.async_all()) == 3 await hass.async_block_till_done() @@ -342,7 +245,6 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass): ): async_fire_time_changed(hass, next_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test1").state == "3" assert hass.states.get("sensor.test2").state == "1" assert hass.states.get("sensor.test3").state == "2" @@ -350,3 +252,20 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass): def _get_fixtures_base_path(): return path.dirname(path.dirname(path.dirname(__file__))) + + +async def async_yaml_patch_helper(hass, filename): + """Help update configuration.yaml.""" + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + f"template/{filename}", + ) + with patch.object(config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b2eb5f06417..bbf866c78a3 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -3,7 +3,6 @@ import logging import pytest -from homeassistant import setup import homeassistant.components.light as light from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -23,320 +22,234 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) -from tests.common import assert_setup_component, async_mock_service - _LOGGER = logging.getLogger(__name__) # Represent for light's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" -@pytest.fixture(name="calls") -def fixture_calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_template_state_invalid(hass): - """Test template state with render error.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{states.test['big.fat...']}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF - - -async def test_template_state_text(hass): - """Test the state text of a template.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ states.light.test_state.state }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.async_set("light.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == STATE_ON - - state = hass.states.async_set("light.test_state", STATE_OFF) - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF - - +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_state,template", - [(STATE_ON, "{{ 1 == 1 }}"), (STATE_OFF, "{{ 1 == 2 }}")], -) -async def test_template_state_boolean(hass, expected_state, template): - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": template, - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("light.test_template_light") - assert state.state == expected_state - - -async def test_template_syntax_error(hass): - """Test templating syntax error.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{%- if false -%}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_name_does_not_create(hass): - """Test invalid name.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "bad name here": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_light_does_not_create(hass): - """Test invalid light.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "switches": {"test_template_light": "Invalid"}, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_no_lights_does_not_create(hass): - """Test if there are no lights no creation.""" - with assert_setup_component(0, light.DOMAIN): - assert await setup.async_setup_component( - hass, "light", {"light": {"platform": "template"}} - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -@pytest.mark.parametrize( - "missing_key, count", [("value_template", 1), ("turn_on", 0), ("turn_off", 0)] -) -async def test_missing_key(hass, missing_key, count): - """Test missing template.""" - light_config = { - "light": { - "platform": "template", - "lights": { - "light_one": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{states.test['big.fat...']}}", + "turn_on": { + "service": "light.turn_on", "entity_id": "light.test_state", - "brightness": "{{brightness}}", }, - }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + ], +) +async def test_template_state_invalid(hass, start_ha): + """Test template state with render error.""" + assert hass.states.get("light.test_template_light").state == STATE_OFF + + +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{ states.light.test_state.state }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + ], +) +async def test_template_state_text(hass, start_ha): + """Test the state text of a template.""" + for set_state in [STATE_ON, STATE_OFF]: + hass.states.async_set("light.test_state", set_state) + await hass.async_block_till_done() + assert hass.states.get("light.test_template_light").state == set_state + + +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config_addon,expected_state", + [ + ({"replace1": '"{{ 1 == 1 }}"'}, STATE_ON), + ({"replace1": '"{{ 1 == 2 }}"'}, STATE_OFF), + ], +) +@pytest.mark.parametrize( + "config", + [ + """{ + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": replace1, + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state" + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state" + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}" + } + } + } } - }, - } - } + } + }""", + ], +) +async def test_templatex_state_boolean(hass, expected_state, start_ha): + """Test the setting of the state with boolean on.""" + assert hass.states.get("light.test_template_light").state == expected_state - del light_config["light"]["lights"]["light_one"][missing_key] - with assert_setup_component(count, light.DOMAIN): - assert await setup.async_setup_component(hass, "light", light_config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +@pytest.mark.parametrize("count,domain", [(0, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{%- if false -%}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + { + "light": { + "platform": "template", + "lights": { + "bad name here": { + "value_template": "{{ 1== 1}}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + { + "light": { + "platform": "template", + "switches": {"test_template_light": "Invalid"}, + } + }, + ], +) +async def test_template_syntax_error(hass, start_ha): + """Test templating syntax error.""" + assert hass.states.async_all() == [] + + +SET_VAL1 = '"value_template": "{{ 1== 1}}",' +SET_VAL2 = '"turn_on": {"service": "light.turn_on","entity_id": "light.test_state"},' +SET_VAL3 = '"turn_off": {"service": "light.turn_off","entity_id": "light.test_state"},' + + +@pytest.mark.parametrize("domain", [light.DOMAIN]) +@pytest.mark.parametrize( + "config_addon, count", + [ + ({"replace2": f"{SET_VAL2}{SET_VAL3}"}, 1), + ({"replace2": f"{SET_VAL1}{SET_VAL2}"}, 0), + ({"replace2": f"{SET_VAL2}{SET_VAL3}"}, 1), + ], +) +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template", "lights": { + "light_one": { + replace2 + "set_level": {"service": "light.turn_on", + "data_template": {"entity_id": "light.test_state","brightness": "{{brightness}}" + }}}}}}""" + ], +) +async def test_missing_key(hass, count, start_ha): + """Test missing template.""" if count: assert hass.states.async_all() != [] else: assert hass.states.async_all() == [] -async def test_on_action(hass, calls): - """Test on action.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -359,12 +272,10 @@ async def test_on_action(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_on_action(hass, start_ha, calls): + """Test on action.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -381,11 +292,10 @@ async def test_on_action(hass, calls): assert len(calls) == 1 -async def test_on_action_with_transition(hass, calls): - """Test on action with transition.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -415,12 +325,10 @@ async def test_on_action_with_transition(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_on_action_with_transition(hass, start_ha, calls): + """Test on action with transition.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -438,11 +346,10 @@ async def test_on_action_with_transition(hass, calls): assert calls[0].data["transition"] == 5 -async def test_on_action_optimistic(hass, calls): - """Test on action with optimistic state.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -464,12 +371,10 @@ async def test_on_action_optimistic(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_on_action_optimistic(hass, start_ha, calls): + """Test on action with optimistic state.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -488,11 +393,10 @@ async def test_on_action_optimistic(hass, calls): assert state.state == STATE_ON -async def test_off_action(hass, calls): - """Test off action.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -517,12 +421,10 @@ async def test_off_action(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_off_action(hass, start_ha, calls): + """Test off action.""" hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -539,11 +441,10 @@ async def test_off_action(hass, calls): assert len(calls) == 1 -async def test_off_action_with_transition(hass, calls): - """Test off action with transition.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -573,12 +474,10 @@ async def test_off_action_with_transition(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_off_action_with_transition(hass, start_ha, calls): + """Test off action with transition.""" hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -596,11 +495,10 @@ async def test_off_action_with_transition(hass, calls): assert calls[0].data["transition"] == 2 -async def test_off_action_optimistic(hass, calls): - """Test off action with optimistic state.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -622,12 +520,10 @@ async def test_off_action_optimistic(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_off_action_optimistic(hass, start_ha, calls): + """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF @@ -643,11 +539,10 @@ async def test_off_action_optimistic(hass, calls): assert state.state == STATE_OFF -async def test_white_value_action_no_template(hass, calls): - """Test setting white value with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -673,11 +568,10 @@ async def test_white_value_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_white_value_action_no_template(hass, start_ha, calls): + """Test setting white value with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("white_value") is None @@ -697,63 +591,43 @@ async def test_white_value_action_no_template(hass, calls): @pytest.mark.parametrize( - "expected_white_value,template", + "expected_white_value,config_addon", [ - (255, "{{255}}"), - (None, "{{256}}"), - (None, "{{x - 12}}"), - (None, "{{ none }}"), - (None, ""), + (255, {"replace3": "{{255}}"}), + (None, {"replace3": "{{256}}"}), + (None, {"replace3": "{{x - 12}}"}), + (None, {"replace3": "{{ none }}"}), + (None, {"replace3": ""}), ], ) -async def test_white_value_template(hass, expected_white_value, template): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + """{ + "light": {"platform": "template","lights": { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_white_value": {"service": "light.turn_on", + "data_template": {"entity_id": "light.test_state", + "white_value": "{{white_value}}"}}, + "white_value_template": "replace3" + }}}}""", + ], +) +async def test_white_value_template(hass, expected_white_value, start_ha): """Test the template for the white value.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_white_value": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "white_value": "{{white_value}}", - }, - }, - "white_value_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("white_value") == expected_white_value -async def test_level_action_no_template(hass, calls): - """Test setting brightness with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -779,11 +653,10 @@ async def test_level_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_level_action_no_template(hass, start_ha, calls): + """Test setting brightness with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("brightness") is None @@ -803,77 +676,54 @@ async def test_level_action_no_template(hass, calls): assert state.attributes.get("brightness") == 124 +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_level,template", + "expected_level,config_addon", [ - (255, "{{255}}"), - (None, "{{256}}"), - (None, "{{x - 12}}"), - (None, "{{ none }}"), - (None, ""), + (255, {"replace4": '"{{255}}"'}), + (None, {"replace4": '"{{256}}"'}), + (None, {"replace4": '"{{x - 12}}"'}), + (None, {"replace4": '"{{ none }}"'}), + (None, {"replace4": '""'}), ], ) -async def test_level_template(hass, expected_level, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template", "lights": { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_level": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","brightness": "{{brightness}}"}}, + "level_template": replace4 + }}}}""", + ], +) +async def test_level_template(hass, expected_level, start_ha): """Test the template for the level.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - "level_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("brightness") == expected_level +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_temp,template", + "expected_temp,config_addon", [ - (500, "{{500}}"), - (None, "{{501}}"), - (None, "{{x - 12}}"), - (None, "None"), - (None, "{{ none }}"), - (None, ""), + (500, {"replace5": '"{{500}}"'}), + (None, {"replace5": '"{{501}}"'}), + (None, {"replace5": '"{{x - 12}}"'}), + (None, {"replace5": '"None"'}), + (None, {"replace5": '"{{ none }}"'}), + (None, {"replace5": '""'}), ], ) -async def test_temperature_template(hass, expected_temp, template): - """Test the template for the temperature.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { +@pytest.mark.parametrize( + "config", + [ + """{ "light": { "platform": "template", "lights": { @@ -881,40 +731,37 @@ async def test_temperature_template(hass, expected_temp, template): "value_template": "{{ 1 == 1 }}", "turn_on": { "service": "light.turn_on", - "entity_id": "light.test_state", + "entity_id": "light.test_state" }, "turn_off": { "service": "light.turn_off", - "entity_id": "light.test_state", + "entity_id": "light.test_state" }, "set_temperature": { "service": "light.turn_on", "data_template": { "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, + "color_temp": "{{color_temp}}" + } }, - "temperature_template": template, + "temperature_template": replace5 } - }, + } } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + }""" + ], +) +async def test_temperature_template(hass, expected_temp, start_ha): + """Test the template for the temperature.""" state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("color_temp") == expected_temp -async def test_temperature_action_no_template(hass, calls): - """Test setting temperature with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -940,11 +787,10 @@ async def test_temperature_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_temperature_action_no_template(hass, start_ha, calls): + """Test setting temperature with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("color_template") is None @@ -964,43 +810,40 @@ async def test_temperature_action_no_template(hass, calls): assert state.attributes.get("color_temp") == 345 -async def test_friendly_name(hass): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + } + }, + } + }, + ], +) +async def test_friendly_name(hass, start_ha): """Test the accessibility of the friendly_name attribute.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() state = hass.states.get("light.test_template_light") assert state is not None @@ -1008,47 +851,43 @@ async def test_friendly_name(hass): assert state.attributes.get("friendly_name") == "Template light" -async def test_icon_template(hass): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + "icon_template": "{% if states.light.test_state.state %}" + "mdi:check" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_icon_template(hass, start_ha): """Test icon template.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - "icon_template": "{% if states.light.test_state.state %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state.attributes.get("icon") == "" @@ -1060,47 +899,43 @@ async def test_icon_template(hass): assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass): +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, + "entity_picture_template": "{% if states.light.test_state.state %}" + "/local/light.png" + "{% endif %}", + } + }, + } + }, + ], +) +async def test_entity_picture_template(hass, start_ha): """Test entity_picture template.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - "entity_picture_template": "{% if states.light.test_state.state %}" - "/local/light.png" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state.attributes.get("entity_picture") == "" @@ -1112,11 +947,10 @@ async def test_entity_picture_template(hass): assert state.attributes["entity_picture"] == "/local/light.png" -async def test_color_action_no_template(hass, calls): - """Test setting color with optimistic template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1153,11 +987,10 @@ async def test_color_action_no_template(hass, calls): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_color_action_no_template(hass, start_ha, calls): + """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -1183,66 +1016,44 @@ async def test_color_action_no_template(hass, calls): assert calls[1].data["s"] == 50 +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_hs,template", + "expected_hs,config_addon", [ - ((360, 100), "{{(360, 100)}}"), - ((359.9, 99.9), "{{(359.9, 99.9)}}"), - (None, "{{(361, 100)}}"), - (None, "{{(360, 101)}}"), - (None, "{{x - 12}}"), - (None, ""), - (None, "{{ none }}"), + ((360, 100), {"replace6": '"{{(360, 100)}}"'}), + ((359.9, 99.9), {"replace6": '"{{(359.9, 99.9)}}"'}), + (None, {"replace6": '"{{(361, 100)}}"'}), + (None, {"replace6": '"{{(360, 101)}}"'}), + (None, {"replace6": '"{{x - 12}}"'}), + (None, {"replace6": '""'}), + (None, {"replace6": '"{{ none }}"'}), ], ) -async def test_color_template(hass, expected_hs, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_color": [{"service": "input_number.set_value", + "data_template": {"entity_id": "input_number.h","color_temp": "{{h}}" + }}], + "color_template": replace6 + }}}}""" + ], +) +async def test_color_template(hass, expected_hs, start_ha): """Test the template for the color.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_color": [ - { - "service": "input_number.set_value", - "data_template": { - "entity_id": "input_number.h", - "color_temp": "{{h}}", - }, - } - ], - "color_template": template, - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("hs_color") == expected_hs -async def test_effect_action_valid_effect(hass, calls): - """Test setting valid effect with template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1274,12 +1085,10 @@ async def test_effect_action_valid_effect(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_effect_action_valid_effect(hass, start_ha, calls): + """Test setting valid effect with template.""" state = hass.states.get("light.test_template_light") assert state is not None @@ -1298,11 +1107,10 @@ async def test_effect_action_valid_effect(hass, calls): assert state.attributes.get("effect") == "Disco" -async def test_effect_action_invalid_effect(hass, calls): - """Test setting invalid effect with template.""" - assert await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1334,12 +1142,10 @@ async def test_effect_action_invalid_effect(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_effect_action_invalid_effect(hass, start_ha, calls): + """Test setting invalid effect with template.""" state = hass.states.get("light.test_template_light") assert state is not None @@ -1358,284 +1164,176 @@ async def test_effect_action_invalid_effect(hass, calls): assert state.attributes.get("effect") is None +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_effect_list,template", + "expected_effect_list,config_addon", [ ( ["Strobe color", "Police", "Christmas", "RGB", "Random Loop"], - "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", + { + "replace7": "\"{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}\"" + }, ), ( ["Police", "RGB", "Random Loop"], - "{{ ['Police', 'RGB', 'Random Loop'] }}", + {"replace7": "\"{{ ['Police', 'RGB', 'Random Loop'] }}\""}, ), - (None, "{{ [] }}"), - (None, "{{ '[]' }}"), - (None, "{{ 124 }}"), - (None, "{{ '124' }}"), - (None, "{{ none }}"), - (None, ""), + (None, {"replace7": '"{{ [] }}"'}), + (None, {"replace7": "\"{{ '[]' }}\""}), + (None, {"replace7": '"{{ 124 }}"'}), + (None, {"replace7": "\"{{ '124' }}\""}), + (None, {"replace7": '"{{ none }}"'}), + (None, {"replace7": '""'}), ], ) -async def test_effect_list_template(hass, expected_effect_list, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_effect": {"service": "test.automation", + "data_template": {"entity_id": "test.test_state","effect": "{{effect}}"}}, + "effect_template": "{{ None }}", + "effect_list_template": replace7 + }}}}""", + ], +) +async def test_effect_list_template(hass, expected_effect_list, start_ha): """Test the template for the effect list.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": template, - "effect_template": "{{ None }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect_list") == expected_effect_list +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_effect,template", + "expected_effect,config_addon", [ - (None, "Disco"), - (None, "None"), - (None, "{{ None }}"), - ("Police", "Police"), - ("Strobe color", "{{ 'Strobe color' }}"), + (None, {"replace8": '"Disco"'}), + (None, {"replace8": '"None"'}), + (None, {"replace8": '"{{ None }}"'}), + ("Police", {"replace8": '"Police"'}), + ("Strobe color", {"replace8": "\"{{ 'Strobe color' }}\""}), ], ) -async def test_effect_template(hass, expected_effect, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_effect": {"service": "test.automation","data_template": { + "entity_id": "test.test_state","effect": "{{effect}}"}}, + "effect_list_template": "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", + "effect_template": replace8 + }}}}""", + ], +) +async def test_effect_template(hass, expected_effect, start_ha): """Test the template for the effect.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - light.DOMAIN, - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", - "effect_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect") == expected_effect +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_min_mireds,template", + "expected_min_mireds,config_addon", [ - (118, "{{118}}"), - (153, "{{x - 12}}"), - (153, "None"), - (153, "{{ none }}"), - (153, ""), - (153, "{{ 'a' }}"), + (118, {"replace9": '"{{118}}"'}), + (153, {"replace9": '"{{x - 12}}"'}), + (153, {"replace9": '"None"'}), + (153, {"replace9": '"{{ none }}"'}), + (153, {"replace9": '""'}), + (153, {"replace9": "\"{{ 'a' }}\""}), ], ) -async def test_min_mireds_template(hass, expected_min_mireds, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_temperature": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","color_temp": "{{color_temp}}"}}, + "temperature_template": "{{200}}", + "min_mireds_template": replace9 + }}}}""", + ], +) +async def test_min_mireds_template(hass, expected_min_mireds, start_ha): """Test the template for the min mireds.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - "light", - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "min_mireds_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("min_mireds") == expected_min_mireds +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_max_mireds,template", + "expected_max_mireds,config_addon", [ - (488, "{{488}}"), - (500, "{{x - 12}}"), - (500, "None"), - (500, "{{ none }}"), - (500, ""), - (500, "{{ 'a' }}"), + (488, {"template1": '"{{488}}"'}), + (500, {"template1": '"{{x - 12}}"'}), + (500, {"template1": '"None"'}), + (500, {"template1": '"{{ none }}"'}), + (500, {"template1": '""'}), + (500, {"template1": "\"{{ 'a' }}\""}), ], ) -async def test_max_mireds_template(hass, expected_max_mireds, template): +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_temperature": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","color_temp": "{{color_temp}}"}}, + "temperature_template": "{{200}}", + "max_mireds_template": template1 + }}}}""", + ], +) +async def test_max_mireds_template(hass, expected_max_mireds, start_ha): """Test the template for the max mireds.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - "light", - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "max_mireds_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("max_mireds") == expected_max_mireds +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) @pytest.mark.parametrize( - "expected_supports_transition,template", + "expected_supports_transition,config_addon", [ - (True, "{{true}}"), - (True, "{{1 == 1}}"), - (False, "{{false}}"), - (False, "{{ none }}"), - (False, ""), - (False, "None"), + (True, {"template2": '"{{true}}"'}), + (True, {"template2": '"{{1 == 1}}"'}), + (False, {"template2": '"{{false}}"'}), + (False, {"template2": '"{{ none }}"'}), + (False, {"template2": '""'}), + (False, {"template2": '"None"'}), + ], +) +@pytest.mark.parametrize( + "config", + [ + """{"light": {"platform": "template","lights": {"test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on","entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off","entity_id": "light.test_state"}, + "set_temperature": {"service": "light.turn_on","data_template": { + "entity_id": "light.test_state","color_temp": "{{color_temp}}"}}, + "supports_transition_template": template2 + }}}}""", ], ) async def test_supports_transition_template( - hass, expected_supports_transition, template + hass, expected_supports_transition, start_ha ): """Test the template for the supports transition.""" - with assert_setup_component(1, light.DOMAIN): - assert await setup.async_setup_component( - hass, - "light", - { - "light": { - "platform": "template", - "lights": { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "supports_transition_template": template, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("light.test_template_light") expected_value = 1 @@ -1649,11 +1347,10 @@ async def test_supports_transition_template( ) != expected_value -async def test_available_template_with_entities(hass): - """Test availability templates with values from other entities.""" - await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1679,11 +1376,10 @@ async def test_available_template_with_entities(hass): }, } }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_available_template_with_entities(hass, start_ha): + """Test availability templates with values from other entities.""" # When template returns true.. hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_ON) await hass.async_block_till_done() @@ -1699,11 +1395,10 @@ async def test_available_template_with_entities(hass): assert hass.states.get("light.test_template_light").state == STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1729,21 +1424,20 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert ("UndefinedError: 'x' is undefined") in caplog_setup_text -async def test_unique_id(hass): - """Test unique_id option only creates one light per id.""" - await setup.async_setup_component( - hass, - light.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, light.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "light": { "platform": "template", @@ -1771,12 +1465,10 @@ async def test_unique_id(hass): }, }, }, - }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one light per id.""" assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 2cbdf23190d..109e4b348b3 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -5,42 +5,30 @@ from homeassistant import setup from homeassistant.components import lock from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from tests.common import assert_setup_component, async_mock_service - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_template_state(hass): - """Test template.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "name": "Test template lock", + "value_template": "{{ states.switch.test_state.state }}", "lock": { - "platform": "template", - "name": "Test template lock", - "value_template": "{{ states.switch.test_state.state }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_state(hass, start_ha): + """Test template.""" hass.states.async_set("switch.test_state", STATE_ON) await hass.async_block_till_done() @@ -54,196 +42,135 @@ async def test_template_state(hass): assert state.state == lock.STATE_UNLOCKED -async def test_template_state_boolean_on(hass): - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 1 == 1 }}", "lock": { - "platform": "template", - "value_template": "{{ 1 == 1 }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_state_boolean_on(hass, start_ha): + """Test the setting of the state with boolean on.""" state = hass.states.get("lock.template_lock") assert state.state == lock.STATE_LOCKED -async def test_template_state_boolean_off(hass): - """Test the setting of the state with off.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 1 == 2 }}", "lock": { - "platform": "template", - "value_template": "{{ 1 == 2 }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_state_boolean_off(hass, start_ha): + """Test the setting of the state with off.""" state = hass.states.get("lock.template_lock") assert state.state == lock.STATE_UNLOCKED -async def test_template_syntax_error(hass): +@pytest.mark.parametrize("count,domain", [(0, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{% if rubbish %}", + "lock": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + { + "switch": { + "platform": "lock", + "name": "{{%}", + "value_template": "{{ rubbish }", + "lock": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + {lock.DOMAIN: {"platform": "template", "value_template": "Invalid"}}, + { + lock.DOMAIN: { + "platform": "template", + "not_value_template": "{{ states.switch.test_state.state }}", + "lock": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_syntax_error(hass, start_ha): """Test templating syntax error.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { + assert hass.states.async_all() == [] + + +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 1 + 1 }}", "lock": { - "platform": "template", - "value_template": "{% if rubbish %}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_name_does_not_create(hass): - """Test invalid name.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "switch": { - "platform": "lock", - "name": "{{%}", - "value_template": "{{ rubbish }", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_lock_does_not_create(hass): - """Test invalid lock.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - {"lock": {"platform": "template", "value_template": "Invalid"}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_template_does_not_create(hass): - """Test missing template.""" - with assert_setup_component(0, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { - "platform": "template", - "not_value_template": "{{ states.switch.test_state.state }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - 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_static(hass, caplog): + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + } + }, + ], +) +async def test_template_static(hass, start_ha): """Test that we allow static templates.""" - with assert_setup_component(1, lock.DOMAIN): - assert await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { - "platform": "template", - "value_template": "{{ 1 + 1 }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") assert state.state == lock.STATE_UNLOCKED @@ -253,13 +180,12 @@ async def test_template_static(hass, caplog): assert state.state == lock.STATE_LOCKED -async def test_lock_action(hass, calls): - """Test lock action.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ states.switch.test_state.state }}", "lock": {"service": "test.automation"}, @@ -269,12 +195,10 @@ async def test_lock_action(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_lock_action(hass, start_ha, calls): + """Test lock action.""" hass.states.async_set("switch.test_state", STATE_OFF) await hass.async_block_till_done() @@ -289,13 +213,12 @@ async def test_lock_action(hass, calls): assert len(calls) == 1 -async def test_unlock_action(hass, calls): - """Test unlock action.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ states.switch.test_state.state }}", "lock": { @@ -305,12 +228,10 @@ async def test_unlock_action(hass, calls): "unlock": {"service": "test.automation"}, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unlock_action(hass, start_ha, calls): + """Test unlock action.""" hass.states.async_set("switch.test_state", STATE_ON) await hass.async_block_till_done() @@ -325,92 +246,38 @@ async def test_unlock_action(hass, calls): assert len(calls) == 1 -async def test_unlocking(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ], +) +@pytest.mark.parametrize( + "test_state", [lock.STATE_UNLOCKING, lock.STATE_LOCKING, lock.STATE_JAMMED] +) +async def test_lock_state(hass, test_state, start_ha): """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) + hass.states.async_set("input_select.test_state", test_state) await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKING + assert state.state == test_state -async def test_locking(hass, calls): - """Test unlocking.""" - assert await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "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.""" - - await setup.async_setup_component( - hass, - lock.DOMAIN, - { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ states('switch.test_state') }}", "lock": {"service": "switch.turn_on", "entity_id": "switch.test_state"}, @@ -421,12 +288,10 @@ async def test_available_template_with_entities(hass): "availability_template": "{{ is_state('availability_state.state', 'on') }}", } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_available_template_with_entities(hass, start_ha): + """Test availability templates with values from other entities.""" # When template returns true.. hass.states.async_set("availability_state.state", STATE_ON) await hass.async_block_till_done() @@ -442,13 +307,12 @@ async def test_available_template_with_entities(hass): assert hass.states.get("lock.template_lock").state == STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "value_template": "{{ 1 + 1 }}", "availability_template": "{{ x - 12 }}", @@ -459,23 +323,22 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert ("UndefinedError: 'x' is undefined") in caplog_setup_text -async def test_unique_id(hass): - """Test unique_id option only creates one lock per id.""" - await setup.async_setup_component( - hass, - lock.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { - "lock": { + lock.DOMAIN: { "platform": "template", "name": "test_template_lock_01", "unique_id": "not-so-unique-anymore", @@ -485,10 +348,12 @@ async def test_unique_id(hass): "service": "switch.turn_off", "entity_id": "switch.test_state", }, - }, + } }, - ) - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one lock per id.""" await setup.async_setup_component( hass, lock.DOMAIN, diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index a606c2ec62b..242ac09d3d0 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -23,116 +23,102 @@ from homeassistant.helpers.template import Template from homeassistant.setup import ATTR_COMPONENT, async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import async_fire_time_changed + +TEST_NAME = "sensor.test_template_sensor" -async def test_template_legacy(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "It {{ states.sensor.test_state.state }}." + } + }, + }, + }, + ], +) +async def test_template_legacy(hass, start_ha): """Test template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "It {{ states.sensor.test_state.state }}." - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.state == "It ." + assert hass.states.get(TEST_NAME).state == "It ." hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.state == "It Works." + assert hass.states.get(TEST_NAME).state == "It Works." -async def test_icon_template(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "icon_template": "{% if states.sensor.test_state.state == " + "'Works' %}" + "mdi:check" + "{% endif %}", + } + }, + }, + }, + ], +) +async def test_icon_template(hass, start_ha): """Test icon template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_state.state }}", - "icon_template": "{% if states.sensor.test_state.state == " - "'Works' %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("icon") == "" + assert hass.states.get(TEST_NAME).attributes.get("icon") == "" hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["icon"] == "mdi:check" + assert hass.states.get(TEST_NAME).attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "entity_picture_template": "{% if states.sensor.test_state.state == " + "'Works' %}" + "/local/sensor.png" + "{% endif %}", + } + }, + }, + }, + ], +) +async def test_entity_picture_template(hass, start_ha): """Test entity_picture template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_state.state }}", - "entity_picture_template": "{% if states.sensor.test_state.state == " - "'Works' %}" - "/local/sensor.png" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("entity_picture") == "" + assert hass.states.get(TEST_NAME).attributes.get("entity_picture") == "" hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["entity_picture"] == "/local/sensor.png" + assert ( + hass.states.get(TEST_NAME).attributes["entity_picture"] == "/local/sensor.png" + ) -async def test_friendly_name_template(hass): - """Test friendly_name template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "attribute,config", + [ + ( + "friendly_name", { "sensor": { "platform": "template", @@ -142,29 +128,11 @@ async def test_friendly_name_template(hass): "friendly_name_template": "It {{ states.sensor.test_state.state }}.", } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("friendly_name") == "It ." - - hass.states.async_set("sensor.test_state", "Works") - await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["friendly_name"] == "It Works." - - -async def test_friendly_name_template_with_unknown_state(hass): - """Test friendly_name template with an unknown value_template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, + ), + ( + "friendly_name", { "sensor": { "platform": "template", @@ -174,29 +142,11 @@ async def test_friendly_name_template_with_unknown_state(hass): "friendly_name_template": "It {{ states.sensor.test_state.state }}.", } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["friendly_name"] == "It ." - - hass.states.async_set("sensor.test_state", "Works") - await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["friendly_name"] == "It Works." - - -async def test_attribute_templates(hass): - """Test attribute_templates template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, + ), + ( + "test_attribute", { "sensor": { "platform": "template", @@ -208,203 +158,131 @@ async def test_attribute_templates(hass): }, } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes.get("test_attribute") == "It ." + ), + ], +) +async def test_friendly_name_template(hass, attribute, start_ha): + """Test friendly_name template with an unknown value_template.""" + assert hass.states.get(TEST_NAME).attributes.get(attribute) == "It ." hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("sensor.test_template_sensor") - assert state.attributes["test_attribute"] == "It Works." + assert hass.states.get(TEST_NAME).attributes[attribute] == "It Works." -async def test_template_syntax_error(hass): - """Test templating syntax error.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": {"value_template": "{% if rubbish %}"} - }, - } +@pytest.mark.parametrize("count,domain", [(0, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": {"value_template": "{% if rubbish %}"} + }, }, - ) - - 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_attribute_missing(hass): - """Test missing attribute template.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "It {{ states.sensor.test_state" - ".attributes.missing }}." - } - }, - } + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test INVALID sensor": { + "value_template": "{{ states.sensor.test_state.state }}" + } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") - assert state.state == STATE_UNAVAILABLE - - -async def test_invalid_name_does_not_create(hass): - """Test invalid name.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": { - "value_template": "{{ states.sensor.test_state.state }}" - } - }, - } + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": {"invalid"}, + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_invalid_sensor_does_not_create(hass): - """Test invalid sensor.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": {"test_template_sensor": "invalid"}, - } + }, + { + "sensor": { + "platform": "template", }, - ) - - await hass.async_block_till_done() - await hass.async_start() - - assert hass.states.async_all() == [] - - -async def test_no_sensors_does_not_create(hass): - """Test no sensors.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, sensor.DOMAIN, {"sensor": {"platform": "template"}} - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_missing_template_does_not_create(hass): - """Test missing template.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "not_value_template": "{{ states.sensor.test_state.state }}" - } - }, - } + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "not_value_template": "{{ states.sensor.test_state.state }}" + } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - -async def test_setup_invalid_device_class(hass): - """Test setup with invalid device_class.""" - with assert_setup_component(0, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { + }, + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { "test": { "value_template": "{{ states.sensor.test_sensor.state }}", "device_class": "foobarnotreal", } - }, - } + } + }, }, - ) + }, + ], +) +async def test_template_syntax_error(hass, start_ha): + """Test setup with invalid device_class.""" + assert hass.states.async_all() == [] -async def test_setup_valid_device_class(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "It {{ states.sensor.test_state" + ".attributes.missing }}." + } + }, + }, + }, + ], +) +async def test_template_attribute_missing(hass, start_ha): + """Test missing attribute template.""" + assert hass.states.get(TEST_NAME).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test1": { + "value_template": "{{ states.sensor.test_sensor.state }}", + "device_class": "temperature", + }, + "test2": { + "value_template": "{{ states.sensor.test_sensor.state }}" + }, + }, + }, + }, + ], +) +async def test_setup_valid_device_class(hass, start_ha): """Test setup with valid device_class.""" - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test1": { - "value_template": "{{ states.sensor.test_sensor.state }}", - "device_class": "temperature", - }, - "test2": { - "value_template": "{{ states.sensor.test_sensor.state }}" - }, - }, - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.test1") - assert state.attributes["device_class"] == "temperature" - state = hass.states.get("sensor.test2") - assert "device_class" not in state.attributes + assert hass.states.get("sensor.test1").attributes["device_class"] == "temperature" + assert "device_class" not in hass.states.get("sensor.test2").attributes @pytest.mark.parametrize("load_registries", [False]) @@ -448,52 +326,46 @@ async def test_creating_sensor_loads_group(hass): assert order == ["group", "sensor.template"] -async def test_available_template_with_entities(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_sensor.state }}", + "availability_template": "{{ is_state('sensor.availability_sensor', 'on') }}", + } + }, + }, + }, + ], +) +async def test_available_template_with_entities(hass, start_ha): """Test availability tempalates with values from other entities.""" hass.states.async_set("sensor.availability_sensor", STATE_OFF) - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_sensor.state }}", - "availability_template": "{{ is_state('sensor.availability_sensor', 'on') }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() # When template returns true.. hass.states.async_set("sensor.availability_sensor", STATE_ON) await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get("sensor.test_template_sensor").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_NAME).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set("sensor.availability_sensor", STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get("sensor.test_template_sensor").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_NAME).state == STATE_UNAVAILABLE -async def test_invalid_attribute_template(hass, caplog): - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("sensor.test_sensor", "startup") - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -505,26 +377,27 @@ async def test_invalid_attribute_template(hass, caplog): }, } }, - } + }, }, - ) + ], +) +async def test_invalid_attribute_template(hass, caplog, start_ha, caplog_setup_text): + """Test that errors are logged if rendering template fails.""" + hass.states.async_set("sensor.test_sensor", "startup") await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity("sensor.invalid_template") - - assert "TemplateError" in caplog.text + assert "TemplateError" in caplog_setup_text assert "test_attribute" in caplog.text -async def test_invalid_availability_template_keeps_component_available(hass, caplog): - """Test that an invalid availability keeps the device available.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -534,16 +407,16 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap "availability_template": "{{ x - 12 }}", } }, - } + }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, start_ha, caplog_setup_text +): + """Test that an invalid availability keeps the device available.""" assert hass.states.get("sensor.my_sensor").state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + assert "UndefinedError: 'x' is undefined" in caplog_setup_text async def test_no_template_match_all(hass, caplog): @@ -632,11 +505,10 @@ async def test_no_template_match_all(hass, caplog): assert hass.states.get("sensor.invalid_attribute").state == "hello" -async def test_unique_id(hass): - """Test unique_id option only creates one sensor per id.""" - await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "unique_id": "group-id", @@ -656,16 +528,13 @@ async def test_unique_id(hass): }, }, }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one sensor per id.""" assert len(hass.states.async_all()) == 2 ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 assert ( ent_reg.async_get_entity_id("sensor", "template", "group-id-sensor-id") @@ -677,17 +546,10 @@ async def test_unique_id(hass): ) -async def test_sun_renders_once_per_sensor(hass): - """Test sun change renders the template only once per sensor.""" - - now = dt_util.utcnow() - hass.states.async_set( - "sun.sun", "above_horizon", {"elevation": 45.3, "next_rising": now} - ) - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -703,10 +565,15 @@ async def test_sun_renders_once_per_sensor(hass): }, } }, - ) + ], +) +async def test_sun_renders_once_per_sensor(hass, start_ha): + """Test sun change renders the template only once per sensor.""" - await hass.async_block_till_done() - await hass.async_start() + now = dt_util.utcnow() + hass.states.async_set( + "sun.sun", "above_horizon", {"elevation": 45.3, "next_rising": now} + ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 3 @@ -738,12 +605,10 @@ async def test_sun_renders_once_per_sensor(hass): } -async def test_self_referencing_sensor_loop(hass, caplog): - """Test a self referencing sensor does not loop forever.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -754,31 +619,23 @@ async def test_self_referencing_sensor_loop(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_sensor_loop(hass, start_ha, caplog_setup_text): + """Test a self referencing sensor does not loop forever.""" assert len(hass.states.async_all()) == 1 - await hass.async_block_till_done() await hass.async_block_till_done() - - assert "Template loop detected" in caplog.text - - state = hass.states.get("sensor.test") - assert int(state.state) == 2 + assert "Template loop detected" in caplog_setup_text + assert int(hass.states.get("sensor.test").state) == 2 await hass.async_block_till_done() - assert int(state.state) == 2 + assert int(hass.states.get("sensor.test").state) == 2 -async def test_self_referencing_sensor_with_icon_loop(hass, caplog): - """Test a self referencing sensor loops forever with a valid self referencing icon.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -790,33 +647,29 @@ async def test_self_referencing_sensor_with_icon_loop(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_sensor_with_icon_loop( + hass, start_ha, caplog_setup_text +): + """Test a self referencing sensor loops forever with a valid self referencing icon.""" assert len(hass.states.async_all()) == 1 - await hass.async_block_till_done() await hass.async_block_till_done() - - assert "Template loop detected" in caplog.text + assert "Template loop detected" in caplog_setup_text state = hass.states.get("sensor.test") assert int(state.state) == 3 assert state.attributes[ATTR_ICON] == "mdi:greater" - await hass.async_block_till_done() + state = hass.states.get("sensor.test") assert int(state.state) == 3 -async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, caplog): - """Test a self referencing sensor loop forevers with a valid self referencing icon.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -829,18 +682,16 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, c }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( + hass, start_ha, caplog_setup_text +): + """Test a self referencing sensor loop forevers with a valid self referencing icon.""" assert len(hass.states.async_all()) == 1 - await hass.async_block_till_done() await hass.async_block_till_done() - - assert "Template loop detected" in caplog.text + assert "Template loop detected" in caplog_setup_text state = hass.states.get("sensor.test") assert int(state.state) == 4 @@ -851,12 +702,10 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, c assert int(state.state) == 4 -async def test_self_referencing_entity_picture_loop(hass, caplog): - """Test a self referencing sensor does not loop forever with a looping self referencing entity picture.""" - - await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -868,14 +717,11 @@ async def test_self_referencing_entity_picture_loop(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_self_referencing_entity_picture_loop(hass, start_ha, caplog_setup_text): + """Test a self referencing sensor does not loop forever with a looping self referencing entity picture.""" assert len(hass.states.async_all()) == 1 - next_time = dt_util.utcnow() + timedelta(seconds=1.2) with patch( "homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time @@ -884,7 +730,7 @@ async def test_self_referencing_entity_picture_loop(hass, caplog): await hass.async_block_till_done() await hass.async_block_till_done() - assert "Template loop detected" in caplog.text + assert "Template loop detected" in caplog_setup_text state = hass.states.get("sensor.test") assert int(state.state) == 1 @@ -969,48 +815,42 @@ async def test_self_referencing_icon_with_no_loop(hass, caplog): assert "Template loop detected" not in caplog.text -async def test_duplicate_templates(hass): +@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "friendly_name_template": "{{ states.sensor.test_state.state }}", + } + }, + } + }, + ], +) +async def test_duplicate_templates(hass, start_ha): """Test template entity where the value and friendly name as the same template.""" hass.states.async_set("sensor.test_state", "Abc") - - with assert_setup_component(1, sensor.DOMAIN): - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.test_state.state }}", - "friendly_name_template": "{{ states.sensor.test_state.state }}", - } - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") + state = hass.states.get(TEST_NAME) assert state.attributes["friendly_name"] == "Abc" assert state.state == "Abc" hass.states.async_set("sensor.test_state", "Def") await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") + state = hass.states.get(TEST_NAME) assert state.attributes["friendly_name"] == "Def" assert state.state == "Def" -async def test_trigger_entity(hass): - """Test trigger entity works.""" - assert await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(2, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": [ {"invalid": "config"}, @@ -1059,10 +899,10 @@ async def test_trigger_entity(hass): }, ], }, - ) - - await hass.async_block_till_done() - + ], +) +async def test_trigger_entity(hass, start_ha): + """Test trigger entity works.""" state = hass.states.get("sensor.hello_name") assert state is not None assert state.state == STATE_UNKNOWN @@ -1106,11 +946,10 @@ async def test_trigger_entity(hass): assert state.context is context -async def test_trigger_entity_render_error(hass): - """Test trigger entity handles render error.""" - assert await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "trigger": {"platform": "event", "event_type": "test_event"}, @@ -1123,10 +962,10 @@ async def test_trigger_entity_render_error(hass): }, }, }, - ) - - await hass.async_block_till_done() - + ], +) +async def test_trigger_entity_render_error(hass, start_ha): + """Test trigger entity handles render error.""" state = hass.states.get("sensor.hello") assert state is not None assert state.state == STATE_UNKNOWN @@ -1143,11 +982,10 @@ async def test_trigger_entity_render_error(hass): assert ent_reg.entities["sensor.hello"].unique_id == "no-base-id" -async def test_trigger_not_allowed_platform_config(hass, caplog): - """Test we throw a helpful warning if a trigger is configured in platform config.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize("count,domain", [(0, sensor.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "sensor": { "platform": "template", @@ -1160,23 +998,22 @@ async def test_trigger_not_allowed_platform_config(hass, caplog): }, } }, - ) - - await hass.async_block_till_done() - - state = hass.states.get("sensor.test_template_sensor") + ], +) +async def test_trigger_not_allowed_platform_config(hass, start_ha, caplog_setup_text): + """Test we throw a helpful warning if a trigger is configured in platform config.""" + state = hass.states.get(TEST_NAME) assert state is None assert ( "You can only add triggers to template entities if they are defined under `template:`." - in caplog.text + in caplog_setup_text ) -async def test_config_top_level(hass): - """Test unique_id option only creates one sensor per id.""" - await async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ { "template": { "sensor": { @@ -1188,10 +1025,10 @@ async def test_config_top_level(hass): }, }, }, - ) - - await hass.async_block_till_done() - + ], +) +async def test_config_top_level(hass, start_ha): + """Test unique_id option only creates one sensor per id.""" assert len(hass.states.async_all()) == 1 state = hass.states.get("sensor.top_level") assert state is not None diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index e9634248c72..72cf41f3528 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -12,33 +12,20 @@ from homeassistant.core import Context, 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, - async_mock_service, - mock_component, -) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 - - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") +from tests.common import async_fire_time_changed, mock_component @pytest.fixture(autouse=True) -def setup_comp(hass): +def setup_comp(hass, calls): """Initialize components.""" mock_component(hass, "group") hass.states.async_set("test.entity", "hello") -async def test_if_fires_on_change_bool(hass, calls): - """Test for firing on boolean change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -51,8 +38,10 @@ async def test_if_fires_on_change_bool(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_bool(hass, start_ha, calls): + """Test for firing on boolean change.""" assert len(calls) == 0 hass.states.async_set("test.entity", "world") @@ -65,309 +54,252 @@ async def test_if_fires_on_change_bool(hass, calls): {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, blocking=True, ) - hass.states.async_set("test.entity", "planet") await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["id"] == 0 -async def test_if_fires_on_change_str(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config, call_setup", + [ + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "world" and "true" }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "world" and "TrUE" }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "world" and false }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": {"platform": "template", "value_template": "true"}, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ "Anything other than true is false." }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ is_state("test.entity", "world") }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ is_state("test.entity", "hello") }}', + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ states.test.entity.state == 'world' }}", + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False), (1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ states.test.entity.state == "hello" }}', + }, + "action": {"service": "test.automation"}, + }, + }, + [(0, "world", True)], + ), + ( + { + automation.DOMAIN: { + "trigger_variables": {"entity": "test.entity"}, + "trigger": { + "platform": "template", + "value_template": '{{ is_state(entity|default("test.entity2"), "hello") }}', + }, + "action": {"service": "test.automation"}, + }, + }, + [(0, "hello", True), (0, "goodbye", True), (1, "hello", True)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": """{%- if is_state("test.entity", "world") -%} + true + {%- else -%} + false + {%- endif -%}""", + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "worldz", False), (0, "hello", True)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": '{{ not is_state("test.entity", "world") }}', + }, + "action": {"service": "test.automation"}, + } + }, + [ + (0, "world", False), + (1, "home", False), + (1, "work", False), + (1, "not_home", False), + (1, "world", False), + (2, "home", False), + ], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ xyz | round(0) }}", + }, + "action": {"service": "test.automation"}, + } + }, + [(0, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ is_state('test.entity', 'world') }}", + "for": {"seconds": 0}, + }, + "action": {"service": "test.automation"}, + } + }, + [(1, "world", False)], + ), + ( + { + automation.DOMAIN: { + "trigger": {"platform": "template", "value_template": "{{ true }}"}, + "action": {"service": "test.automation"}, + } + }, + [(0, "hello", False)], + ), + ], +) +async def test_general(hass, call_setup, start_ha, calls): """Test for firing on change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "world" and "true" }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - assert len(calls) == 0 - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 + for call_len, call_name, call_force in call_setup: + hass.states.async_set("test.entity", call_name, force_update=call_force) + await hass.async_block_till_done() + assert len(calls) == call_len -async def test_if_fires_on_change_str_crazy(hass, calls): - """Test for firing on change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "world" and "TrUE" }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_not_fires_when_true_at_setup(hass, calls): - """Test for not firing during startup.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "hello" }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - assert len(calls) == 0 - - hass.states.async_set("test.entity", "hello", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_not_fires_when_true_at_setup_variables(hass, calls): - """Test for not firing during startup + trigger_variables.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger_variables": {"entity": "test.entity"}, - "trigger": { - "platform": "template", - "value_template": '{{ is_state(entity|default("test.entity2"), "hello") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - assert len(calls) == 0 - - # Assert that the trigger doesn't fire immediately when it's setup - # If trigger_variable 'entity' is not passed to initial check at setup, the - # trigger will immediately fire - hass.states.async_set("test.entity", "hello", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 0 - - hass.states.async_set("test.entity", "goodbye", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 0 - - # Assert that the trigger fires after state change - # If trigger_variable 'entity' is not passed to the template trigger, the - # trigger will never fire because it falls back to 'test.entity2' - hass.states.async_set("test.entity", "hello", force_update=True) - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_not_fires_because_fail(hass, calls): +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config, call_setup", + [ + ( + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ 84 / states.test.number.state|int == 42 }}", + }, + "action": {"service": "test.automation"}, + } + }, + [ + (0, "1"), + (1, "2"), + (1, "0"), + (1, "2"), + ], + ), + ], +) +async def test_if_not_fires_because_fail(hass, call_setup, start_ha, calls): """Test for not firing after TemplateError.""" - hass.states.async_set("test.number", "1") - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ 84 / states.test.number.state|int == 42 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - assert len(calls) == 0 - hass.states.async_set("test.number", "2") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.number", "0") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.number", "2") - await hass.async_block_till_done() - assert len(calls) == 1 + for call_len, call_number in call_setup: + hass.states.async_set("test.number", call_number) + await hass.async_block_till_done() + assert len(calls) == call_len -async def test_if_not_fires_on_change_bool(hass, calls): - """Test for not firing on boolean change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ states.test.entity.state == "world" and false }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_not_fires_on_change_str(hass, calls): - """Test for not firing on string change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "template", "value_template": "true"}, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_not_fires_on_change_str_crazy(hass, calls): - """Test for not firing on string change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ "Anything other than true is false." }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_fires_on_no_change(hass, calls): - """Test for firing on no change.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "template", "value_template": "{{ true }}"}, - "action": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - cur_len = len(calls) - - hass.states.async_set("test.entity", "hello") - await hass.async_block_till_done() - assert cur_len == len(calls) - - -async def test_if_fires_on_two_change(hass, calls): - """Test for firing on two changes.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ states.test.entity.state == 'world' }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # Trigger once - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - # Trigger again - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_fires_on_change_with_template(hass, calls): - """Test for firing on change with template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ is_state("test.entity", "world") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_not_fires_on_change_with_template(hass, calls): - """Test for not firing on change with template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ is_state("test.entity", "hello") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_fires_on_change_with_template_advanced(hass, calls): - """Test for firing on change with template advanced.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -391,8 +323,11 @@ async def test_if_fires_on_change_with_template_advanced(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_template_advanced(hass, start_ha, calls): + """Test for firing on change with template advanced.""" + context = Context() await hass.async_block_till_done() hass.states.async_set("test.entity", "world", context=context) @@ -402,85 +337,10 @@ async def test_if_fires_on_change_with_template_advanced(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - None" -async def test_if_fires_on_no_change_with_template_advanced(hass, calls): - """Test for firing on no change with template advanced.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": """{%- if is_state("test.entity", "world") -%} - true - {%- else -%} - false - {%- endif -%}""", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # Different state - hass.states.async_set("test.entity", "worldz") - await hass.async_block_till_done() - assert len(calls) == 0 - - # Different state - hass.states.async_set("test.entity", "hello") - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_if_fires_on_change_with_template_2(hass, calls): - """Test for firing on change with template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": '{{ not is_state("test.entity", "world") }}', - }, - "action": {"service": "test.automation"}, - } - }, - ) - - await hass.async_block_till_done() - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - - hass.states.async_set("test.entity", "home") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "work") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "not_home") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - hass.states.async_set("test.entity", "home") - await hass.async_block_till_done() - assert len(calls) == 2 - - -async def test_if_action(hass, calls): - """Test for firing if action.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": {"platform": "event", "event_type": "test_event"}, @@ -493,8 +353,10 @@ async def test_if_action(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_action(hass, start_ha, calls): + """Test for firing if action.""" # Condition is not true yet hass.bus.async_fire("test_event") await hass.async_block_till_done() @@ -511,47 +373,26 @@ async def test_if_action(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_bad_template(hass, calls): - """Test for firing on change with bad template.""" - with assert_setup_component(0, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "template", "value_template": "{{ "}, - "action": {"service": "test.automation"}, - } - }, - ) - - -async def test_if_fires_on_change_with_bad_template_2(hass, calls): - """Test for firing on change with bad template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(0, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ xyz | round(0) }}", - }, + "trigger": {"platform": "template", "value_template": "{{ "}, "action": {"service": "test.automation"}, } }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 0 + ], +) +async def test_if_fires_on_change_with_bad_template(hass, start_ha, calls): + """Test for firing on change with bad template.""" -async def test_wait_template_with_trigger(hass, calls): - """Test using wait template with 'trigger.entity_id'.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -579,8 +420,10 @@ async def test_wait_template_with_trigger(hass, calls): ], } }, - ) - + ], +) +async def test_wait_template_with_trigger(hass, start_ha, calls): + """Test using wait template with 'trigger.entity_id'.""" await hass.async_block_till_done() @callback @@ -620,12 +463,10 @@ async def test_if_fires_on_change_with_for(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_advanced(hass, calls): - """Test for firing on change with for advanced.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -650,8 +491,11 @@ async def test_if_fires_on_change_with_for_advanced(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_advanced(hass, start_ha, calls): + """Test for firing on change with for advanced.""" + context = Context() await hass.async_block_till_done() hass.states.async_set("test.entity", "world", context=context) @@ -664,34 +508,10 @@ async def test_if_fires_on_change_with_for_advanced(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - 0:00:05" -async def test_if_fires_on_change_with_for_0(hass, calls): - """Test for firing on change with for: 0.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ is_state('test.entity', 'world') }}", - "for": {"seconds": 0}, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert len(calls) == 1 - - -async def test_if_fires_on_change_with_for_0_advanced(hass, calls): - """Test for firing on change with for: 0 advanced.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -716,8 +536,11 @@ async def test_if_fires_on_change_with_for_0_advanced(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_0_advanced(hass, start_ha, calls): + """Test for firing on change with for: 0 advanced.""" + context = Context() await hass.async_block_till_done() hass.states.async_set("test.entity", "world", context=context) @@ -727,12 +550,10 @@ async def test_if_fires_on_change_with_for_0_advanced(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - 0:00:00" -async def test_if_fires_on_change_with_for_2(hass, calls): - """Test for firing on change with for.""" - context = Context() - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -757,8 +578,11 @@ async def test_if_fires_on_change_with_for_2(hass, calls): }, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_2(hass, start_ha, calls): + """Test for firing on change with for.""" + context = Context() hass.states.async_set("test.entity", "world", context=context) await hass.async_block_till_done() assert len(calls) == 0 @@ -769,11 +593,10 @@ async def test_if_fires_on_change_with_for_2(hass, calls): assert calls[0].data["some"] == "template - test.entity - hello - world - 0:00:05" -async def test_if_not_fires_on_change_with_for(hass, calls): - """Test for firing on change with for.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -784,8 +607,10 @@ async def test_if_not_fires_on_change_with_for(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_not_fires_on_change_with_for(hass, start_ha, calls): + """Test for firing on change with for.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -800,11 +625,10 @@ async def test_if_not_fires_on_change_with_for(hass, calls): assert len(calls) == 0 -async def test_if_not_fires_when_turned_off_with_for(hass, calls): - """Test for firing on change with for.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -815,8 +639,10 @@ async def test_if_not_fires_when_turned_off_with_for(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_not_fires_when_turned_off_with_for(hass, start_ha, calls): + """Test for firing on change with for.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -835,11 +661,10 @@ async def test_if_not_fires_when_turned_off_with_for(hass, calls): assert len(calls) == 0 -async def test_if_fires_on_change_with_for_template_1(hass, calls): - """Test for firing on change with for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -850,8 +675,10 @@ async def test_if_fires_on_change_with_for_template_1(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_template_1(hass, start_ha, calls): + """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -860,11 +687,10 @@ async def test_if_fires_on_change_with_for_template_1(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_2(hass, calls): - """Test for firing on change with for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -875,8 +701,10 @@ async def test_if_fires_on_change_with_for_template_2(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_template_2(hass, start_ha, calls): + """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -885,11 +713,10 @@ async def test_if_fires_on_change_with_for_template_2(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_3(hass, calls): - """Test for firing on change with for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -900,8 +727,10 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_if_fires_on_change_with_for_template_3(hass, start_ha, calls): + """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") await hass.async_block_till_done() assert len(calls) == 0 @@ -910,11 +739,10 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls): assert len(calls) == 1 -async def test_invalid_for_template_1(hass, calls): - """Test for invalid for template.""" - assert await async_setup_component( - hass, - automation.DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, automation.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { automation.DOMAIN: { "trigger": { @@ -925,8 +753,10 @@ async def test_invalid_for_template_1(hass, calls): "action": {"service": "test.automation"}, } }, - ) - + ], +) +async def test_invalid_for_template_1(hass, start_ha, calls): + """Test for invalid for template.""" with mock.patch.object(template_trigger, "_LOGGER") as mock_logger: hass.states.async_set("test.entity", "world") await hass.async_block_till_done() diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6e0252845d1..2bd6063b6ef 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -207,12 +207,11 @@ async def test_available_template_with_entities(hass, start_ha): ], ) async def test_invalid_availability_template_keeps_component_available( - hass, caplog, start_ha + hass, start_ha, caplog_setup_text ): """Test that an invalid availability keeps the device available.""" assert hass.states.get("vacuum.test_template_vacuum") != STATE_UNAVAILABLE - text = str([x.getMessage() for x in caplog.get_records("setup")]) - assert ("UndefinedError: \\'x\\' is undefined") in text + assert "UndefinedError: 'x' is undefined" in caplog_setup_text @pytest.mark.parametrize( @@ -275,13 +274,11 @@ async def test_attribute_templates(hass, start_ha): ) ], ) -async def test_invalid_attribute_template(hass, caplog, start_ha): +async def test_invalid_attribute_template(hass, start_ha, caplog_setup_text): """Test that errors are logged if rendering template fails.""" assert len(hass.states.async_all()) == 1 - - text = str([x.getMessage() for x in caplog.get_records("setup")]) - assert "test_attribute" in text - assert "TemplateError" in text + assert "test_attribute" in caplog_setup_text + assert "TemplateError" in caplog_setup_text @pytest.mark.parametrize( diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 649a54aa3aa..c112473ecd6 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -1,4 +1,6 @@ """The tests for the Template Weather platform.""" +import pytest + from homeassistant.components.weather import ( ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_HUMIDITY, @@ -10,14 +12,12 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, DOMAIN, ) -from homeassistant.setup import async_setup_component -async def test_template_state_text(hass): - """Test the state text of a template.""" - await async_setup_component( - hass, - DOMAIN, +@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ { "weather": [ {"weather": {"platform": "demo"}}, @@ -37,40 +37,27 @@ async def test_template_state_text(hass): }, ] }, - ) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - hass.states.async_set("sensor.attribution", "The custom attribution") - await hass.async_block_till_done() - hass.states.async_set("sensor.temperature", 22.3) - await hass.async_block_till_done() - hass.states.async_set("sensor.humidity", 60) - await hass.async_block_till_done() - hass.states.async_set("sensor.pressure", 1000) - await hass.async_block_till_done() - hass.states.async_set("sensor.windspeed", 20) - await hass.async_block_till_done() - hass.states.async_set("sensor.windbearing", 180) - await hass.async_block_till_done() - hass.states.async_set("sensor.ozone", 25) - await hass.async_block_till_done() - hass.states.async_set("sensor.visibility", 4.6) - await hass.async_block_till_done() - - state = hass.states.get("weather.test") - assert state is not None - - assert state.state == "sunny" - - data = state.attributes - assert data.get(ATTR_WEATHER_ATTRIBUTION) == "The custom attribution" - assert data.get(ATTR_WEATHER_TEMPERATURE) == 22.3 - assert data.get(ATTR_WEATHER_HUMIDITY) == 60 - assert data.get(ATTR_WEATHER_PRESSURE) == 1000 - assert data.get(ATTR_WEATHER_WIND_SPEED) == 20 - assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert data.get(ATTR_WEATHER_OZONE) == 25 - assert data.get(ATTR_WEATHER_VISIBILITY) == 4.6 + ], +) +async def test_template_state_text(hass, start_ha): + """Test the state text of a template.""" + for attr, v_attr, value in [ + ( + "sensor.attribution", + ATTR_WEATHER_ATTRIBUTION, + "The custom attribution", + ), + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ("sensor.pressure", ATTR_WEATHER_PRESSURE, 1000), + ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), + ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), + ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + assert state is not None + assert state.state == "sunny" + assert state.attributes.get(v_attr) == value diff --git a/tests/components/tesla/__init__.py b/tests/components/tesla/__init__.py deleted file mode 100644 index 89b1e1c0c54..00000000000 --- a/tests/components/tesla/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Tesla integration.""" diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py deleted file mode 100644 index 4a45aac5124..00000000000 --- a/tests/components/tesla/test_config_flow.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Test the Tesla config flow.""" -import datetime -from unittest.mock import patch - -from teslajsonpy.exceptions import IncompleteCredentials, TeslaException - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.tesla.const import ( - CONF_EXPIRATION, - CONF_WAKE_ON_START, - DEFAULT_SCAN_INTERVAL, - DEFAULT_WAKE_ON_START, - DOMAIN, - MIN_SCAN_INTERVAL, -) -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - HTTP_NOT_FOUND, -) - -from tests.common import MockConfigEntry - -TEST_USERNAME = "test-username" -TEST_TOKEN = "test-token" -TEST_PASSWORD = "test-password" -TEST_ACCESS_TOKEN = "test-access-token" -TEST_VALID_EXPIRATION = datetime.datetime.now().timestamp() * 2 - - -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"] == {} - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ), patch( - "homeassistant.components.tesla.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.tesla.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "test", CONF_USERNAME: "test@email.com"} - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "test@email.com" - assert result2["data"] == { - CONF_USERNAME: "test@email.com", - CONF_PASSWORD: "test", - CONF_TOKEN: TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.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( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - side_effect=TeslaException(401), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_invalid_auth_incomplete_credentials(hass): - """Test we handle invalid auth with incomplete credentials.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - side_effect=IncompleteCredentials(401), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, - ) - - 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( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - side_effect=TeslaException(code=HTTP_NOT_FOUND), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_repeat_identifier(hass): - """Test we handle repeat identifiers.""" - entry = MockConfigEntry( - domain=DOMAIN, - title=TEST_USERNAME, - data={"username": TEST_USERNAME, "password": TEST_PASSWORD}, - options=None, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, - ) - - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" - - -async def test_form_reauth(hass): - """Test we handle reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - title=TEST_USERNAME, - data={"username": TEST_USERNAME, "password": "same"}, - options=None, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH}, - data={"username": TEST_USERNAME}, - ) - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "new-password"}, - ) - - assert result2["type"] == "abort" - assert result2["reason"] == "reauth_successful" - - -async def test_import(hass): - """Test import step.""" - - with patch( - "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value={ - "refresh_token": TEST_TOKEN, - CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, - CONF_EXPIRATION: TEST_VALID_EXPIRATION, - }, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == TEST_USERNAME - assert result["data"][CONF_ACCESS_TOKEN] == TEST_ACCESS_TOKEN - assert result["data"][CONF_TOKEN] == TEST_TOKEN - assert result["description_placeholders"] is None - - -async def test_option_flow(hass): - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == "form" - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True}, - ) - assert result["type"] == "create_entry" - assert result["data"] == {CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True} - - -async def test_option_flow_defaults(hass): - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == "form" - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, - } - - -async def test_option_flow_input_floor(hass): - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == "form" - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_SCAN_INTERVAL: 1} - ) - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL, - CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, - } diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index f3240991a37..a98db508bb4 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -41,7 +41,7 @@ async def test_abort_if_no_configuration(hass): async def test_full_flow_implementation( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test registering an integration and finishing flow works.""" await setup_component(hass) @@ -75,7 +75,7 @@ async def test_full_flow_implementation( "&tenant_id=eneco&issuer=identity.toon.eu" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -105,7 +105,7 @@ async def test_full_flow_implementation( async def test_no_agreements( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test abort when there are no displays.""" await setup_component(hass) @@ -125,7 +125,7 @@ async def test_no_agreements( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", @@ -145,7 +145,7 @@ async def test_no_agreements( async def test_multiple_agreements( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test abort when there are no displays.""" await setup_component(hass) @@ -165,7 +165,7 @@ async def test_multiple_agreements( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( @@ -195,7 +195,7 @@ async def test_multiple_agreements( async def test_agreement_already_set_up( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test showing display form again if display already exists.""" await setup_component(hass) @@ -216,7 +216,7 @@ async def test_agreement_already_set_up( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", @@ -236,7 +236,7 @@ async def test_agreement_already_set_up( async def test_toon_abort( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test we abort on Toon error.""" await setup_component(hass) @@ -255,7 +255,7 @@ async def test_toon_abort( result["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", @@ -289,7 +289,7 @@ async def test_import(hass, current_request_with_host): async def test_import_migration( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Test if importing step with migration works.""" old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1) @@ -317,7 +317,7 @@ async def test_import_migration( flows[0]["flow_id"], {"implementation": "eneco"} ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.get(f"/auth/external/callback?code=abcd&state={state}") aioclient_mock.post( "https://api.toon.eu/token", diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 865c6c1d97a..870e05e970b 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -1 +1,118 @@ """Tests for the TP-Link component.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from kasa import SmartBulb, SmartPlug, SmartStrip +from kasa.exceptions import SmartDeviceException +from kasa.protocol import TPLinkSmartHomeProtocol + +MODULE = "homeassistant.components.tplink" +MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" +IP_ADDRESS = "127.0.0.1" +ALIAS = "My Bulb" +MODEL = "HS100" +MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" + + +def _mock_protocol() -> TPLinkSmartHomeProtocol: + protocol = MagicMock(auto_spec=TPLinkSmartHomeProtocol) + protocol.close = AsyncMock() + return protocol + + +def _mocked_bulb() -> SmartBulb: + bulb = MagicMock(auto_spec=SmartBulb) + bulb.update = AsyncMock() + bulb.mac = MAC_ADDRESS + bulb.alias = ALIAS + bulb.model = MODEL + bulb.host = IP_ADDRESS + bulb.brightness = 50 + bulb.color_temp = 4000 + bulb.is_color = True + bulb.is_strip = False + bulb.is_plug = False + bulb.hsv = (10, 30, 5) + bulb.device_id = MAC_ADDRESS + bulb.valid_temperature_range.min = 4000 + bulb.valid_temperature_range.max = 9000 + bulb.hw_info = {"sw_ver": "1.0.0"} + bulb.turn_off = AsyncMock() + bulb.turn_on = AsyncMock() + bulb.set_brightness = AsyncMock() + bulb.set_hsv = AsyncMock() + bulb.set_color_temp = AsyncMock() + bulb.protocol = _mock_protocol() + return bulb + + +def _mocked_plug() -> SmartPlug: + plug = MagicMock(auto_spec=SmartPlug) + plug.update = AsyncMock() + plug.mac = MAC_ADDRESS + plug.alias = "My Plug" + plug.model = MODEL + plug.host = IP_ADDRESS + plug.is_light_strip = False + plug.is_bulb = False + plug.is_dimmer = False + plug.is_strip = False + plug.is_plug = True + plug.device_id = MAC_ADDRESS + plug.hw_info = {"sw_ver": "1.0.0"} + plug.turn_off = AsyncMock() + plug.turn_on = AsyncMock() + plug.protocol = _mock_protocol() + return plug + + +def _mocked_strip() -> SmartStrip: + strip = MagicMock(auto_spec=SmartStrip) + strip.update = AsyncMock() + strip.mac = MAC_ADDRESS + strip.alias = "My Strip" + strip.model = MODEL + strip.host = IP_ADDRESS + strip.is_light_strip = False + strip.is_bulb = False + strip.is_dimmer = False + strip.is_strip = True + strip.is_plug = True + strip.device_id = MAC_ADDRESS + strip.hw_info = {"sw_ver": "1.0.0"} + strip.turn_off = AsyncMock() + strip.turn_on = AsyncMock() + strip.protocol = _mock_protocol() + plug0 = _mocked_plug() + plug0.alias = "Plug0" + plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" + plug0.mac = "bb:bb:cc:dd:ee:ff" + plug0.protocol = _mock_protocol() + plug1 = _mocked_plug() + plug1.device_id = "cc:bb:cc:dd:ee:ff_PLUG1DEVICEID" + plug1.mac = "cc:bb:cc:dd:ee:ff" + plug1.alias = "Plug1" + plug1.protocol = _mock_protocol() + strip.children = [plug0, plug1] + return strip + + +def _patch_discovery(device=None, no_device=False): + async def _discovery(*args, **kwargs): + if no_device: + return {} + return {IP_ADDRESS: _mocked_bulb()} + + return patch("homeassistant.components.tplink.Discover.discover", new=_discovery) + + +def _patch_single_discovery(device=None, no_device=False): + async def _discover_single(*_): + if no_device: + raise SmartDeviceException + return device if device else _mocked_bulb() + + return patch( + "homeassistant.components.tplink.Discover.discover_single", new=_discover_single + ) diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 61b242c5d2e..1963b595176 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,2 +1,27 @@ """tplink conftest.""" -from tests.components.light.conftest import mock_light_profiles # noqa: F401 + +import pytest + +from . import _patch_discovery + +from tests.common import mock_device_registry, mock_registry + + +@pytest.fixture +def mock_discovery(): + """Mock python-kasa discovery.""" + with _patch_discovery() as mock_discover: + mock_discover.return_value = {} + yield mock_discover + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py new file mode 100644 index 00000000000..3c875f623dd --- /dev/null +++ b/tests/components/tplink/test_config_flow.py @@ -0,0 +1,477 @@ +"""Test the tplink config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.tplink import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM + +from . import ( + ALIAS, + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + MODULE, + _patch_discovery, + _patch_single_discovery, +) + +from tests.common import MockConfigEntry + + +async def test_discovery(hass: HomeAssistant): + """Test setting up discovery.""" + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # test we can try again + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == {CONF_HOST: IP_ADDRESS} + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_discovery_with_existing_device_present(hass: HomeAssistant): + """Test setting up discovery.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.2"}, unique_id="dd:dd:dd:dd:dd:dd" + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_single_discovery(no_device=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # Now abort and make sure we can start over + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE: MAC_ADDRESS} + ) + assert result3["type"] == "create_entry" + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == { + CONF_HOST: IP_ADDRESS, + } + await hass.async_block_till_done() + + mock_setup_entry.assert_called_once() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_discovery_no_device(hass: HomeAssistant): + """Test discovery without device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_discovery(no_device=True), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_import(hass: HomeAssistant): + """Test import from yaml.""" + config = { + CONF_HOST: IP_ADDRESS, + } + + # Cannot connect + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # Success + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_ENTRY_TITLE + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + } + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + # Duplicate + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_manual(hass: HomeAssistant): + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ), patch(f"{MODULE}.async_setup_entry", return_value=True): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] == "create_entry" + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_HOST: IP_ADDRESS, + } + + # Duplicate + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_manual_no_capabilities(hass: HomeAssistant): + """Test manually setup without successful get_capabilities.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(no_device=True), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ), patch(f"{MODULE}.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + } + + +async def test_discovered_by_discovery_and_dhcp(hass): + """Test we get the form with discovery and abort for dhcp source when we get both.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_single_discovery(): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + with _patch_discovery(), _patch_single_discovery(): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={"ip": IP_ADDRESS, "macaddress": "00:00:00:00:00:00"}, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_in_progress" + + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={"ip": "1.2.3.5", "macaddress": "00:00:00:00:00:01"}, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + "source, data", + [ + ( + config_entries.SOURCE_DHCP, + {"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + ), + ( + config_entries.SOURCE_DISCOVERY, + {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + ), + ], +) +async def test_discovered_by_dhcp_or_discovery(hass, source, data): + """Test we can setup when discovered from dhcp or discovery.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_discovery(), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +@pytest.mark.parametrize( + "source, data", + [ + ( + config_entries.SOURCE_DHCP, + {"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + ), + ( + config_entries.SOURCE_DISCOVERY, + {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + ), + ], +) +async def test_discovered_by_dhcp_or_discovery_failed_to_get_device(hass, source, data): + """Test we abort if we cannot get the unique id when discovered from dhcp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_migration_device_online(hass: HomeAssistant): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + config = {CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS, CONF_HOST: IP_ADDRESS} + + with _patch_discovery(), _patch_single_discovery(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "migration"}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == ALIAS + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + } + assert len(mock_setup_entry.mock_calls) == 2 + + # Duplicate + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "migration"}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_migration_device_offline(hass: HomeAssistant): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + config = {CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS, CONF_HOST: None} + + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "migration"}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == ALIAS + new_entry = result["result"] + assert result["data"] == { + CONF_HOST: None, + } + assert len(mock_setup_entry.mock_calls) == 2 + + # Ensure a manual import updates the missing host + config = {CONF_HOST: IP_ADDRESS} + with _patch_discovery(no_device=True), _patch_single_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert new_entry.data[CONF_HOST] == IP_ADDRESS diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index d96d6846939..c3f7e814ed6 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,69 +1,22 @@ """Tests for the TP-Link component.""" from __future__ import annotations -import time -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch -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.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.components.tplink.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState 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, async_fire_time_changed, mock_coro -from tests.components.tplink.consts import ( - SMARTPLUG_HS100_DATA, - SMARTPLUG_HS110_DATA, - SMARTSTRIP_KP303_DATA, -) +from . import IP_ADDRESS, MAC_ADDRESS, _patch_discovery, _patch_single_discovery - -async def test_creating_entry_tries_discover(hass): - """Test setting up does discovery.""" - with patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup, patch( - "homeassistant.components.tplink.common.Discover.discover", - return_value={"host": 1234}, - ): - result = await hass.config_entries.flow.async_init( - tplink.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - # Confirmation form - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - await hass.async_block_till_done() - - assert len(mock_setup.mock_calls) == 1 +from tests.common import MockConfigEntry async def test_configuring_tplink_causes_discovery(hass): """Test that specifying empty config does discovery.""" - with patch("homeassistant.components.tplink.common.Discover.discover") as discover: + with patch("homeassistant.components.tplink.Discover.discover") as discover: discover.return_value = {"host": 1234} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -71,371 +24,28 @@ async def test_configuring_tplink_causes_discovery(hass): assert len(discover.mock_calls) == 1 -@pytest.mark.parametrize( - "name,cls,platform", - [ - ("pyHS100.SmartPlug", SmartPlug, "switch"), - ("pyHS100.SmartBulb", SmartBulb, "light"), - ], -) -@pytest.mark.parametrize("count", [1, 2, 3]) -async def test_configuring_device_types(hass, name, cls, platform, count): - """Test that light or switch platform list is filled correctly.""" - 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=True, - ): - discovery_data = { - f"123.123.123.{c}": cls("123.123.123.123") for c in range(count) - } - discover.return_value = discovery_data +async def test_config_entry_reload(hass): + """Test that a config entry can be reloaded.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_discovery(), _patch_single_discovery(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - - assert len(discover.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN][platform]) == count - - -class UnknownSmartDevice(SmartDevice): - """Dummy class for testing.""" - - @property - def has_emeter(self) -> bool: - """Do nothing.""" - - def turn_off(self) -> None: - """Do nothing.""" - - def turn_on(self) -> None: - """Do nothing.""" - - @property - def is_on(self) -> bool: - """Do nothing.""" - - @property - def state_information(self) -> dict[str, Any]: - """Do nothing.""" - - -async def test_configuring_devices_from_multiple_sources(hass): - """Test static and discover devices are not duplicated.""" - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" - ): - discover_device_fail = SmartPlug("123.123.123.123") - discover_device_fail.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) - - discover.return_value = { - "123.123.123.1": SmartBulb("123.123.123.1"), - "123.123.123.2": SmartPlug("123.123.123.2"), - "123.123.123.3": SmartBulb("123.123.123.3"), - "123.123.123.4": SmartPlug("123.123.123.4"), - "123.123.123.123": discover_device_fail, - "123.123.123.124": UnknownSmartDevice("123.123.123.124"), - } - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_LIGHT: [{CONF_HOST: "123.123.123.1"}], - CONF_SWITCH: [{CONF_HOST: "123.123.123.2"}], - CONF_DIMMER: [{CONF_HOST: "123.123.123.22"}], - } - }, - ) + assert already_migrated_config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) await hass.async_block_till_done() - - assert len(discover.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 3 - assert len(hass.data[tplink.DOMAIN][CONF_SWITCH]) == 2 + assert already_migrated_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_is_dimmable(hass): - """Test that is_dimmable switches are correctly added as lights.""" - with patch( - "homeassistant.components.tplink.common.Discover.discover" - ) as discover, patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), - ) as setup, patch( - "homeassistant.components.tplink.common.SmartDevice._query_helper" - ), patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", True - ): - dimmable_switch = SmartPlug("123.123.123.123") - discover.return_value = {"host": dimmable_switch} - +async def test_config_entry_retry(hass): + """Test that a config entry can be retried.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - - assert len(discover.mock_calls) == 1 - assert len(setup.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 1 - assert not hass.data[tplink.DOMAIN][CONF_SWITCH] - - -async def test_configuring_discovery_disabled(hass): - """Test that discover does not get called when disabled.""" - with patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup, patch( - "homeassistant.components.tplink.common.Discover.discover", return_value=[] - ) as discover: - await async_setup_component( - hass, tplink.DOMAIN, {tplink.DOMAIN: {tplink.CONF_DISCOVERY: False}} - ) - await hass.async_block_till_done() - - assert discover.call_count == 0 - assert mock_setup.call_count == 1 - - -async def test_platforms_are_initialized(hass: HomeAssistant): - """Test that platforms are initialized per configuration array.""" - config = { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - 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, - ): - - 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.common.SmartPlug.get_sysinfo", - return_value=SMARTSTRIP_KP303_DATA["sysinfo"], - ): - - 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() - - entities = hass.states.async_entity_ids(SWITCH_DOMAIN) - assert len(entities) == 3 - - -async def test_no_config_creates_no_entry(hass): - """Test for when there is no tplink in config.""" - with patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup: - await async_setup_component(hass, tplink.DOMAIN, {}) - await hass.async_block_till_done() - - 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.""" - # As we have currently no configuration, we just to pass the domain here. - entry = MockConfigEntry(domain=tplink.DOMAIN) - 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 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(async_setup_entry.mock_calls) == 1 - assert tplink.DOMAIN in hass.data - - assert await tplink.async_unload_entry(hass, entry) - assert not hass.data[tplink.DOMAIN] + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 1854e714902..19116005c37 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -1,752 +1,284 @@ """Tests for light platform.""" -from datetime import timedelta -import logging -from typing import Callable, NamedTuple -from unittest.mock import Mock, PropertyMock, patch -from pyHS100 import SmartDeviceException import pytest from homeassistant.components import tplink -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, -) from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.tplink.const import ( - CONF_DIMMER, - CONF_DISCOVERY, - CONF_LIGHT, -) -from homeassistant.components.tplink.light import SLEEP_TIME -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_HOST, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from . import MAC_ADDRESS, _mocked_bulb, _patch_discovery, _patch_single_discovery + +from tests.common import MockConfigEntry -class LightMockData(NamedTuple): - """Mock light data.""" - - sys_info: dict - light_state: dict - set_light_state: Callable[[dict], None] - set_light_state_mock: Mock - get_light_state_mock: Mock - current_consumption_mock: Mock - get_sysinfo_mock: Mock - get_emeter_daily_mock: Mock - get_emeter_monthly_mock: Mock - - -class SmartSwitchMockData(NamedTuple): - """Mock smart switch data.""" - - sys_info: dict - state_mock: Mock - brightness_mock: Mock - get_sysinfo_mock: Mock - - -@pytest.fixture(name="unknown_light_mock_data") -def unknown_light_mock_data_fixture() -> None: - """Create light mock data.""" - sys_info = { - "sw_ver": "1.2.3", - "hw_ver": "2.3.4", - "mac": "aa:bb:cc:dd:ee:ff", - "mic_mac": "00:11:22:33:44", - "type": "light", - "hwId": "1234", - "fwId": "4567", - "oemId": "891011", - "dev_name": "light1", - "rssi": 11, - "latitude": "0", - "longitude": "0", - "is_color": True, - "is_dimmable": True, - "is_variable_color_temp": True, - "model": "Foo", - "alias": "light1", - } - light_state = { - "on_off": True, - "dft_on_state": { - "brightness": 12, - "color_temp": 3200, - "hue": 110, - "saturation": 90, - }, - "brightness": 13, - "color_temp": 3300, - "hue": 110, - "saturation": 90, - } - - def set_light_state(state) -> None: - nonlocal light_state - drt_on_state = light_state["dft_on_state"] - drt_on_state.update(state.get("dft_on_state", {})) - - light_state.update(state) - light_state["dft_on_state"] = drt_on_state - return light_state - - set_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.set_light_state", - side_effect=set_light_state, +async def test_light_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) - get_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.get_light_state", - return_value=light_state, - ) - current_consumption_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.current_consumption", - return_value=3.23, - ) - get_sysinfo_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - return_value=sys_info, - ) - get_emeter_daily_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_daily", - return_value={ - 1: 1.01, - 2: 1.02, - 3: 1.03, - 4: 1.04, - 5: 1.05, - 6: 1.06, - 7: 1.07, - 8: 1.08, - 9: 1.09, - 10: 1.10, - 11: 1.11, - 12: 1.12, - }, - ) - get_emeter_monthly_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_monthly", - return_value={ - 1: 2.01, - 2: 2.02, - 3: 2.03, - 4: 2.04, - 5: 2.05, - 6: 2.06, - 7: 2.07, - 8: 2.08, - 9: 2.09, - 10: 2.10, - 11: 2.11, - 12: 2.12, - }, - ) - - with set_light_state_patch as set_light_state_mock, get_light_state_patch as get_light_state_mock, current_consumption_patch as current_consumption_mock, get_sysinfo_patch as get_sysinfo_mock, get_emeter_daily_patch as get_emeter_daily_mock, get_emeter_monthly_patch as get_emeter_monthly_mock: - yield LightMockData( - sys_info=sys_info, - light_state=light_state, - set_light_state=set_light_state, - set_light_state_mock=set_light_state_mock, - get_light_state_mock=get_light_state_mock, - current_consumption_mock=current_consumption_mock, - get_sysinfo_mock=get_sysinfo_mock, - get_emeter_daily_mock=get_emeter_daily_mock, - get_emeter_monthly_mock=get_emeter_monthly_mock, - ) - - -@pytest.fixture(name="light_mock_data") -def light_mock_data_fixture() -> None: - """Create light mock data.""" - sys_info = { - "sw_ver": "1.2.3", - "hw_ver": "2.3.4", - "mac": "aa:bb:cc:dd:ee:ff", - "mic_mac": "00:11:22:33:44", - "type": "light", - "hwId": "1234", - "fwId": "4567", - "oemId": "891011", - "dev_name": "light1", - "rssi": 11, - "latitude": "0", - "longitude": "0", - "is_color": True, - "is_dimmable": True, - "is_variable_color_temp": True, - "model": "LB120", - "alias": "light1", - } - - light_state = { - "on_off": True, - "dft_on_state": { - "brightness": 12, - "color_temp": 3200, - "hue": 110, - "saturation": 90, - }, - "brightness": 13, - "color_temp": 3300, - "hue": 110, - "saturation": 90, - } - - def set_light_state(state) -> None: - nonlocal light_state - drt_on_state = light_state["dft_on_state"] - drt_on_state.update(state.get("dft_on_state", {})) - - light_state.update(state) - light_state["dft_on_state"] = drt_on_state - return light_state - - set_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.set_light_state", - side_effect=set_light_state, - ) - get_light_state_patch = patch( - "homeassistant.components.tplink.common.SmartBulb.get_light_state", - return_value=light_state, - ) - current_consumption_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.current_consumption", - return_value=3.23, - ) - get_sysinfo_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - return_value=sys_info, - ) - get_emeter_daily_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_daily", - return_value={ - 1: 1.01, - 2: 1.02, - 3: 1.03, - 4: 1.04, - 5: 1.05, - 6: 1.06, - 7: 1.07, - 8: 1.08, - 9: 1.09, - 10: 1.10, - 11: 1.11, - 12: 1.12, - }, - ) - get_emeter_monthly_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_emeter_monthly", - return_value={ - 1: 2.01, - 2: 2.02, - 3: 2.03, - 4: 2.04, - 5: 2.05, - 6: 2.06, - 7: 2.07, - 8: 2.08, - 9: 2.09, - 10: 2.10, - 11: 2.11, - 12: 2.12, - }, - ) - - with set_light_state_patch as set_light_state_mock, get_light_state_patch as get_light_state_mock, current_consumption_patch as current_consumption_mock, get_sysinfo_patch as get_sysinfo_mock, get_emeter_daily_patch as get_emeter_daily_mock, get_emeter_monthly_patch as get_emeter_monthly_mock: - yield LightMockData( - sys_info=sys_info, - light_state=light_state, - set_light_state=set_light_state, - set_light_state_mock=set_light_state_mock, - get_light_state_mock=get_light_state_mock, - current_consumption_mock=current_consumption_mock, - get_sysinfo_mock=get_sysinfo_mock, - get_emeter_daily_mock=get_emeter_daily_mock, - get_emeter_monthly_mock=get_emeter_monthly_mock, - ) - - -@pytest.fixture(name="dimmer_switch_mock_data") -def dimmer_switch_mock_data_fixture() -> None: - """Create dimmer switch mock data.""" - sys_info = { - "sw_ver": "1.2.3", - "hw_ver": "2.3.4", - "mac": "aa:bb:cc:dd:ee:ff", - "mic_mac": "00:11:22:33:44", - "type": "switch", - "hwId": "1234", - "fwId": "4567", - "oemId": "891011", - "dev_name": "dimmer1", - "rssi": 11, - "latitude": "0", - "longitude": "0", - "is_color": False, - "is_dimmable": True, - "is_variable_color_temp": False, - "model": "HS220", - "alias": "dimmer1", - "feature": ":", - "relay_state": 1, - "brightness": 13, - } - - def state(*args, **kwargs): - nonlocal sys_info - if len(args) == 0: - return sys_info["relay_state"] - if args[0] == "ON": - sys_info["relay_state"] = 1 - else: - sys_info["relay_state"] = 0 - - def brightness(*args, **kwargs): - nonlocal sys_info - if len(args) == 0: - return sys_info["brightness"] - if sys_info["brightness"] == 0: - sys_info["relay_state"] = 0 - else: - sys_info["relay_state"] = 1 - sys_info["brightness"] = args[0] - - get_sysinfo_patch = patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - return_value=sys_info, - ) - state_patch = patch( - "homeassistant.components.tplink.common.SmartPlug.state", - new_callable=PropertyMock, - side_effect=state, - ) - brightness_patch = patch( - "homeassistant.components.tplink.common.SmartPlug.brightness", - new_callable=PropertyMock, - side_effect=brightness, - ) - with brightness_patch as brightness_mock, state_patch as state_mock, get_sysinfo_patch as get_sysinfo_mock: - yield SmartSwitchMockData( - sys_info=sys_info, - brightness_mock=brightness_mock, - state_mock=state_mock, - get_sysinfo_mock=get_sysinfo_mock, - ) - - -async def update_entity(hass: HomeAssistant, entity_id: str) -> None: - """Run an update action for an entity.""" - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - await hass.async_block_till_done() - - -async def test_smartswitch( - hass: HomeAssistant, dimmer_switch_mock_data: SmartSwitchMockData -) -> None: - """Test function.""" - sys_info = dimmer_switch_mock_data.sys_info - - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_DIMMER: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - assert hass.states.get("light.dimmer1") - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - assert hass.states.get("light.dimmer1").state == "off" - assert sys_info["relay_state"] == 0 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer1", ATTR_BRIGHTNESS: 50}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "on" - assert state.attributes["brightness"] == 51 - assert sys_info["relay_state"] == 1 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer1", ATTR_BRIGHTNESS: 55}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "on" - assert state.attributes["brightness"] == 56 - assert sys_info["brightness"] == 22 - - sys_info["relay_state"] = 0 - sys_info["brightness"] = 66 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "off" - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.dimmer1") - - state = hass.states.get("light.dimmer1") - assert state.state == "on" - assert state.attributes["brightness"] == 168 - assert sys_info["brightness"] == 66 - - -async def test_unknown_light( - hass: HomeAssistant, unknown_light_mock_data: LightMockData -) -> None: - """Test function.""" - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["min_mireds"] == 200 - assert state.attributes["max_mireds"] == 370 - - -async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> None: - """Test function.""" - light_state = light_mock_data.light_state - set_light_state = light_mock_data.set_light_state - - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - assert hass.states.get("light.light1") - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - assert hass.states.get("light.light1").state == "off" - assert light_state["on_off"] == 0 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1", ATTR_COLOR_TEMP: 222, ATTR_BRIGHTNESS: 50}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 51 - assert state.attributes["color_temp"] == 222 - assert "hs_color" in state.attributes - assert light_state["on_off"] == 1 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1", ATTR_BRIGHTNESS: 55, ATTR_HS_COLOR: (23, 27)}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 56 - assert state.attributes["hs_color"] == (23, 27) - assert "color_temp" not in state.attributes - assert light_state["brightness"] == 22 - assert light_state["hue"] == 23 - assert light_state["saturation"] == 27 - - light_state["on_off"] = 0 - light_state["dft_on_state"]["on_off"] = 0 - light_state["brightness"] = 66 - light_state["dft_on_state"]["brightness"] = 66 - light_state["color_temp"] = 6400 - light_state["dft_on_state"]["color_temp"] = 123 - light_state["hue"] = 77 - light_state["dft_on_state"]["hue"] = 77 - light_state["saturation"] = 78 - light_state["dft_on_state"]["saturation"] = 78 - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "off" - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 168 - assert state.attributes["color_temp"] == 156 - assert "hs_color" in state.attributes - assert light_state["brightness"] == 66 - assert light_state["hue"] == 77 - assert light_state["saturation"] == 78 - - set_light_state({"brightness": 91, "dft_on_state": {"brightness": 91}}) - await update_entity(hass, "light.light1") - - state = hass.states.get("light.light1") - assert state.attributes["brightness"] == 232 - - -async def test_get_light_state_retry( - hass: HomeAssistant, light_mock_data: LightMockData -) -> None: - """Test function.""" - # Setup test for retries for sysinfo. - get_sysinfo_call_count = 0 - - def get_sysinfo_side_effect(): - nonlocal get_sysinfo_call_count - get_sysinfo_call_count += 1 - - # Need to fail on the 2nd call because the first call is used to - # determine if the device is online during the light platform's - # setup hook. - if get_sysinfo_call_count == 2: - raise SmartDeviceException() - - return light_mock_data.sys_info - - light_mock_data.get_sysinfo_mock.side_effect = get_sysinfo_side_effect - - # Setup test for retries of setting state information. - set_state_call_count = 0 - - def set_light_state_side_effect(state_data: dict): - nonlocal set_state_call_count, light_mock_data - set_state_call_count += 1 - - if set_state_call_count == 1: - raise SmartDeviceException() - - return light_mock_data.set_light_state(state_data) - - light_mock_data.set_light_state_mock.side_effect = set_light_state_side_effect - - # Setup component. - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) - await hass.async_block_till_done() - await update_entity(hass, "light.light1") - - assert light_mock_data.get_sysinfo_mock.call_count > 1 - assert light_mock_data.get_light_state_mock.call_count > 1 - assert light_mock_data.set_light_state_mock.call_count > 1 - - assert light_mock_data.get_sysinfo_mock.call_count < 40 - assert light_mock_data.get_light_state_mock.call_count < 40 - assert light_mock_data.set_light_state_mock.call_count < 10 - - -async def test_update_failure( - hass: HomeAssistant, light_mock_data: LightMockData, caplog -): - """Test that update failures are logged.""" - - await async_setup_component(hass, HA_DOMAIN, {}) - await hass.async_block_till_done() - - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - caplog.clear() - caplog.set_level(logging.WARNING) - await hass.helpers.entity_component.async_update_entity("light.light1") - assert caplog.text == "" - - with patch("homeassistant.components.tplink.light.MAX_ATTEMPTS", 0): - caplog.clear() - caplog.set_level(logging.WARNING) - await hass.helpers.entity_component.async_update_entity("light.light1") - assert "Could not read state for 123.123.123.123|light1" in caplog.text - - get_state_call_count = 0 - - def get_light_state_side_effect(): - nonlocal get_state_call_count - get_state_call_count += 1 - - if get_state_call_count == 1: - raise SmartDeviceException() - - return light_mock_data.light_state - - light_mock_data.get_light_state_mock.side_effect = get_light_state_side_effect - - with patch("homeassistant.components.tplink.light", MAX_ATTEMPTS=2, SLEEP_TIME=0): - caplog.clear() - caplog.set_level(logging.DEBUG) - - await update_entity(hass, "light.light1") - assert ( - f"Retrying in {SLEEP_TIME} seconds for 123.123.123.123|light1" - in caplog.text - ) - assert "Device 123.123.123.123|light1 responded after " in caplog.text - - -async def test_async_setup_entry_unavailable( - hass: HomeAssistant, light_mock_data: LightMockData, caplog -): - """Test unavailable devices trigger a later retry.""" - caplog.clear() - caplog.set_level(logging.WARNING) - - with patch( - "homeassistant.components.tplink.common.SmartDevice.get_sysinfo", - side_effect=SmartDeviceException, - ): - await async_setup_component(hass, HA_DOMAIN, {}) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) + entity_id = "light.my_bulb" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "AABBCCDDEEFF" + +async def test_color_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - assert not hass.states.get("light.light1") - future = utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get("light.light1") + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "hs" + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp", "hs"] + assert attributes[ATTR_MIN_MIREDS] == 111 + assert attributes[ATTR_MAX_MIREDS] == 250 + assert attributes[ATTR_HS_COLOR] == (10, 30) + assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) + assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.set_brightness.assert_called_with(39, transition=None) + bulb.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.set_hsv.assert_called_with(10, 30, None, transition=None) + bulb.set_hsv.reset_mock() + + +@pytest.mark.parametrize("is_color", [True, False]) +async def test_color_temp_light(hass: HomeAssistant, is_color: bool) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = is_color + bulb.color_temp = 4000 + bulb.is_variable_color_temp = True + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "color_temp" + if bulb.is_color: + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + "brightness", + "color_temp", + "hs", + ] + else: + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp"] + assert attributes[ATTR_MIN_MIREDS] == 111 + assert attributes[ATTR_MAX_MIREDS] == 250 + assert attributes[ATTR_COLOR_TEMP] == 250 + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.set_brightness.assert_called_with(39, transition=None) + bulb.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + +async def test_brightness_only_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = False + bulb.is_variable_color_temp = False + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "brightness" + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.set_brightness.assert_called_with(39, transition=None) + bulb.set_brightness.reset_mock() + + +async def test_on_off_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = False + bulb.is_variable_color_temp = False + bulb.is_dimmable = False + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_off.assert_called_once() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.turn_on.assert_called_once() + bulb.turn_on.reset_mock() + + +async def test_off_at_start_light(hass: HomeAssistant) -> None: + """Test a light.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.is_color = False + bulb.is_variable_color_temp = False + bulb.is_dimmable = False + bulb.is_on = False + + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "off" + attributes = state.attributes + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] diff --git a/tests/components/tplink/test_migration.py b/tests/components/tplink/test_migration.py new file mode 100644 index 00000000000..6cd82448ca2 --- /dev/null +++ b/tests/components/tplink/test_migration.py @@ -0,0 +1,241 @@ +"""Test the tplink config flow.""" + +from homeassistant import setup +from homeassistant.components.tplink import CONF_DISCOVERY, CONF_SWITCH, DOMAIN +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import ALIAS, IP_ADDRESS, MAC_ADDRESS, _patch_discovery, _patch_single_discovery + +from tests.common import MockConfigEntry + + +async def test_migration_device_online_end_to_end( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + device = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + name=ALIAS, + ) + switch_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="switch", + unique_id=MAC_ADDRESS, + original_name=ALIAS, + device_id=device.id, + ) + light_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=dr.format_mac(MAC_ADDRESS), + original_name=ALIAS, + device_id=device.id, + ) + power_sensor_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id=f"{MAC_ADDRESS}_sensor", + original_name=ALIAS, + device_id=device.id, + ) + + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + migrated_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + migrated_entry = entry + break + + assert migrated_entry is not None + + assert device.config_entries == {migrated_entry.entry_id} + assert light_entity_reg.config_entry_id == migrated_entry.entry_id + assert switch_entity_reg.config_entry_id == migrated_entry.entry_id + assert power_sensor_entity_reg.config_entry_id == migrated_entry.entry_id + assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + legacy_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + legacy_entry = entry + break + + assert legacy_entry is None + + +async def test_migration_device_online_end_to_end_after_downgrade( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +): + """Test migration from single config entry can happen again after a downgrade.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + device = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + name=ALIAS, + ) + light_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=MAC_ADDRESS, + original_name=ALIAS, + device_id=device.id, + ) + power_sensor_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id=f"{MAC_ADDRESS}_sensor", + original_name=ALIAS, + device_id=device.id, + ) + + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert device.config_entries == {config_entry.entry_id} + assert light_entity_reg.config_entry_id == config_entry.entry_id + assert power_sensor_entity_reg.config_entry_id == config_entry.entry_id + assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + legacy_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + legacy_entry = entry + break + + assert legacy_entry is None + + +async def test_migration_device_online_end_to_end_ignores_other_devices( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +): + """Test migration from single config entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + other_domain_config_entry = MockConfigEntry( + domain="other_domain", data={}, unique_id="other_domain" + ) + other_domain_config_entry.add_to_hass(hass) + device = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + name=ALIAS, + ) + other_device = device_reg.async_get_or_create( + config_entry_id=other_domain_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "556655665566")}, + name=ALIAS, + ) + light_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=MAC_ADDRESS, + original_name=ALIAS, + device_id=device.id, + ) + power_sensor_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id=f"{MAC_ADDRESS}_sensor", + original_name=ALIAS, + device_id=device.id, + ) + ignored_entity_reg = entity_reg.async_get_or_create( + config_entry=other_domain_config_entry, + platform=DOMAIN, + domain="sensor", + unique_id="00:00:00:00:00:00_sensor", + original_name=ALIAS, + device_id=device.id, + ) + garbage_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="sensor", + unique_id="garbage", + original_name=ALIAS, + device_id=other_device.id, + ) + + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + migrated_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + migrated_entry = entry + break + + assert migrated_entry is not None + + assert device.config_entries == {migrated_entry.entry_id} + assert light_entity_reg.config_entry_id == migrated_entry.entry_id + assert power_sensor_entity_reg.config_entry_id == migrated_entry.entry_id + assert ignored_entity_reg.config_entry_id == other_domain_config_entry.entry_id + assert garbage_entity_reg.config_entry_id == config_entry.entry_id + + assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + legacy_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == DOMAIN: + legacy_entry = entry + break + + assert legacy_entry is not None + + +async def test_migrate_from_yaml(hass: HomeAssistant): + """Test migrate from yaml.""" + config = { + DOMAIN: { + CONF_DISCOVERY: False, + CONF_SWITCH: [{CONF_HOST: IP_ADDRESS}], + } + } + with _patch_discovery(), _patch_single_discovery(): + await setup.async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + migrated_entry = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == MAC_ADDRESS: + migrated_entry = entry + break + + assert migrated_entry is not None + assert migrated_entry.data[CONF_HOST] == IP_ADDRESS diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py new file mode 100644 index 00000000000..5413e036d96 --- /dev/null +++ b/tests/components/tplink/test_sensor.py @@ -0,0 +1,155 @@ +"""Tests for light platform.""" + +from unittest.mock import Mock + +from homeassistant.components import tplink +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + MAC_ADDRESS, + _mocked_bulb, + _mocked_plug, + _patch_discovery, + _patch_single_discovery, +) + +from tests.common import MockConfigEntry + + +async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: + """Test a light with an emeter.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + bulb.has_emeter = True + bulb.emeter_realtime = Mock( + power=None, + total=None, + voltage=None, + current=5, + ) + bulb.emeter_today = 5000.0036 + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + expected = { + "sensor.my_bulb_today_s_consumption": 5000.004, + "sensor.my_bulb_current": 5, + } + entity_id = "light.my_bulb" + state = hass.states.get(entity_id) + assert state.state == "on" + for sensor_entity_id, value in expected.items(): + assert hass.states.get(sensor_entity_id).state == str(value) + + not_expected = { + "sensor.my_bulb_current_consumption", + "sensor.my_bulb_total_consumption", + "sensor.my_bulb_voltage", + } + for sensor_entity_id in not_expected: + assert hass.states.get(sensor_entity_id) is None + + +async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: + """Test a plug with an emeter.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + plug.color_temp = None + plug.has_emeter = True + plug.emeter_realtime = Mock( + power=100.06, + total=30.0049, + voltage=121.19, + current=5.035, + ) + plug.emeter_today = None + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + expected = { + "sensor.my_plug_current_consumption": 100.1, + "sensor.my_plug_total_consumption": 30.005, + "sensor.my_plug_today_s_consumption": 0.0, + "sensor.my_plug_voltage": 121.2, + "sensor.my_plug_current": 5.04, + } + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == "on" + for sensor_entity_id, value in expected.items(): + assert hass.states.get(sensor_entity_id).state == str(value) + + +async def test_color_light_no_emeter(hass: HomeAssistant) -> None: + """Test a light without an emeter.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_temp = None + bulb.has_emeter = False + with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + state = hass.states.get(entity_id) + assert state.state == "on" + + not_expected = [ + "sensor.my_bulb_current_consumption" + "sensor.my_bulb_total_consumption" + "sensor.my_bulb_today_s_consumption" + "sensor.my_bulb_voltage" + "sensor.my_bulb_current" + ] + for sensor_entity_id in not_expected: + assert hass.states.get(sensor_entity_id) is None + + +async def test_sensor_unique_id(hass: HomeAssistant) -> None: + """Test a sensor unique ids.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + plug.color_temp = None + plug.has_emeter = True + plug.emeter_realtime = Mock( + power=100, + total=30, + voltage=121, + current=5, + ) + plug.emeter_today = None + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + expected = { + "sensor.my_plug_current_consumption": "aa:bb:cc:dd:ee:ff_current_power_w", + "sensor.my_plug_total_consumption": "aa:bb:cc:dd:ee:ff_total_energy_kwh", + "sensor.my_plug_today_s_consumption": "aa:bb:cc:dd:ee:ff_today_energy_kwh", + "sensor.my_plug_voltage": "aa:bb:cc:dd:ee:ff_voltage", + "sensor.my_plug_current": "aa:bb:cc:dd:ee:ff_current_a", + } + entity_registry = er.async_get(hass) + for sensor_entity_id, value in expected.items(): + assert entity_registry.async_get(sensor_entity_id).unique_id == value diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py new file mode 100644 index 00000000000..9e7f9189aab --- /dev/null +++ b/tests/components/tplink/test_switch.py @@ -0,0 +1,143 @@ +"""Tests for switch platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from kasa import SmartDeviceException + +from homeassistant.components import tplink +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import ( + MAC_ADDRESS, + _mocked_plug, + _mocked_strip, + _patch_discovery, + _patch_single_discovery, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_plug(hass: HomeAssistant) -> None: + """Test a smart plug.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + plug.turn_off.assert_called_once() + plug.turn_off.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + plug.turn_on.assert_called_once() + plug.turn_on.reset_mock() + + +async def test_plug_unique_id(hass: HomeAssistant) -> None: + """Test a plug unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" + + +async def test_plug_update_fails(hass: HomeAssistant) -> None: + """Test a smart plug update failure.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + plug.update = AsyncMock(side_effect=SmartDeviceException) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_strip(hass: HomeAssistant) -> None: + """Test a smart strip.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_strip() + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + # Verify we only create entities for the children + # since this is what the previous version did + assert hass.states.get("switch.my_strip") is None + + for plug_id in range(2): + entity_id = f"switch.plug{plug_id}" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + strip.children[plug_id].turn_off.assert_called_once() + strip.children[plug_id].turn_off.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + strip.children[plug_id].turn_on.assert_called_once() + strip.children[plug_id].turn_on.reset_mock() + + +async def test_strip_unique_ids(hass: HomeAssistant) -> None: + """Test a strip unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_strip() + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + for plug_id in range(2): + entity_id = f"switch.plug{plug_id}" + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get(entity_id).unique_id == f"PLUG{plug_id}DEVICEID" + ) diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index 5e995e10e92..2bc46bc94a7 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -28,7 +28,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture(name="client") -async def traccar_client(loop, hass, aiohttp_client): +async def traccar_client(loop, hass, hass_client_no_auth): """Mock client for Traccar (unauthenticated).""" assert await async_setup_component(hass, "persistent_notification", {}) @@ -37,7 +37,7 @@ async def traccar_client(loop, hass, aiohttp_client): await hass.async_block_till_done() with patch("homeassistant.components.device_tracker.legacy.update_config"): - return await aiohttp_client(hass.http.app) + return await hass_client_no_auth() @pytest.fixture(autouse=True) diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 6ea28cf8d2b..a5aee459bd7 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -1,80 +1,85 @@ """Tests for the Tuya config flow.""" -from unittest.mock import Mock, patch +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch import pytest -from tuyaha.devices.climate import STEP_HALVES -from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException from homeassistant import config_entries, data_entry_flow -from homeassistant.components.tuya.config_flow import ( - CONF_LIST_DEVICES, - ERROR_DEV_MULTI_TYPE, - ERROR_DEV_NOT_CONFIG, - ERROR_DEV_NOT_FOUND, - RESULT_AUTH_FAILED, - RESULT_CONN_ERROR, - RESULT_SINGLE_INSTANCE, -) from homeassistant.components.tuya.const import ( - CONF_BRIGHTNESS_RANGE_MODE, - CONF_COUNTRYCODE, - CONF_CURR_TEMP_DIVIDER, - CONF_DISCOVERY_INTERVAL, - CONF_MAX_KELVIN, - CONF_MAX_TEMP, - CONF_MIN_KELVIN, - CONF_MIN_TEMP, - CONF_QUERY_DEVICE, - CONF_QUERY_INTERVAL, - CONF_SET_TEMP_DIVIDED, - CONF_SUPPORT_COLOR, - CONF_TEMP_DIVIDER, - CONF_TEMP_STEP_OVERRIDE, - CONF_TUYA_MAX_COLTEMP, - DOMAIN, - TUYA_DATA, -) -from homeassistant.const import ( + CONF_ACCESS_ID, + CONF_ACCESS_SECRET, + CONF_APP_TYPE, + CONF_AUTH_TYPE, + CONF_COUNTRY_CODE, + CONF_ENDPOINT, CONF_PASSWORD, - CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, - TEMP_CELSIUS, + DOMAIN, + ENDPOINT_INDIA, + SMARTLIFE_APP, + TUYA_COUNTRIES, + TUYA_SMART_APP, ) +from homeassistant.core import HomeAssistant -from .common import CLIMATE_ID, LIGHT_ID, LIGHT_ID_FAKE1, LIGHT_ID_FAKE2, MockTuya +MOCK_SMART_HOME_PROJECT_TYPE = 0 +MOCK_INDUSTRY_PROJECT_TYPE = 1 -from tests.common import MockConfigEntry +MOCK_COUNTRY = "India" +MOCK_ACCESS_ID = "myAccessId" +MOCK_ACCESS_SECRET = "myAccessSecret" +MOCK_USERNAME = "myUsername" +MOCK_PASSWORD = "myPassword" +MOCK_ENDPOINT = ENDPOINT_INDIA -USERNAME = "myUsername" -PASSWORD = "myPassword" -COUNTRY_CODE = "1" -TUYA_PLATFORM = "tuya" - -TUYA_USER_DATA = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_COUNTRYCODE: COUNTRY_CODE, - CONF_PLATFORM: TUYA_PLATFORM, +TUYA_INPUT_DATA = { + CONF_COUNTRY_CODE: MOCK_COUNTRY, + CONF_ACCESS_ID: MOCK_ACCESS_ID, + CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, } +RESPONSE_SUCCESS = { + "success": True, + "code": 1024, + "result": {"platform_url": MOCK_ENDPOINT}, +} +RESPONSE_ERROR = {"success": False, "code": 123, "msg": "Error"} + @pytest.fixture(name="tuya") -def tuya_fixture() -> Mock: +def tuya_fixture() -> MagicMock: """Patch libraries.""" - with patch("homeassistant.components.tuya.config_flow.TuyaApi") as tuya: + with patch("homeassistant.components.tuya.config_flow.TuyaOpenAPI") as tuya: yield tuya @pytest.fixture(name="tuya_setup", autouse=True) -def tuya_setup_fixture(): +def tuya_setup_fixture() -> None: """Mock tuya entry setup.""" with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): yield -async def test_user(hass, tuya): - """Test user config.""" +@pytest.mark.parametrize( + "app_type,side_effects, project_type", + [ + ("", [RESPONSE_SUCCESS], 1), + (TUYA_SMART_APP, [RESPONSE_ERROR, RESPONSE_SUCCESS], 0), + (SMARTLIFE_APP, [RESPONSE_ERROR, RESPONSE_ERROR, RESPONSE_SUCCESS], 0), + ], +) +async def test_user_flow( + hass: HomeAssistant, + tuya: MagicMock, + app_type: str, + side_effects: list[dict[str, Any]], + project_type: int, +): + """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -82,193 +87,43 @@ async def test_user(hass, tuya): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" + tuya().connect = MagicMock(side_effect=side_effects) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_USER_DATA + result["flow_id"], user_input=TUYA_INPUT_DATA ) await hass.async_block_till_done() + country = [country for country in TUYA_COUNTRIES if country.name == MOCK_COUNTRY][0] + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_COUNTRYCODE] == COUNTRY_CODE - assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM + assert result["title"] == MOCK_USERNAME + assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID + assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET + assert result["data"][CONF_USERNAME] == MOCK_USERNAME + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + assert result["data"][CONF_ENDPOINT] == country.endpoint + assert result["data"][CONF_APP_TYPE] == app_type + assert result["data"][CONF_AUTH_TYPE] == project_type + assert result["data"][CONF_COUNTRY_CODE] == country.country_code assert not result["result"].unique_id -async def test_abort_if_already_setup(hass, tuya): - """Test we abort if Tuya is already setup.""" - MockConfigEntry(domain=DOMAIN, data=TUYA_USER_DATA).add_to_hass(hass) - - # Should fail, config exist (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == RESULT_SINGLE_INSTANCE - - -async def test_abort_on_invalid_credentials(hass, tuya): +async def test_error_on_invalid_credentials(hass, tuya): """Test when we have invalid credentials.""" - tuya().init.side_effect = TuyaAPIException("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": RESULT_AUTH_FAILED} + assert result["step_id"] == "user" - -async def test_abort_on_connection_error(hass, tuya): - """Test when we have a network error.""" - tuya().init.side_effect = TuyaNetException("Boom") - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + tuya().connect = MagicMock(return_value=RESPONSE_ERROR) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_INPUT_DATA ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == RESULT_CONN_ERROR - - -async def test_options_flow(hass): - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TUYA_USER_DATA, - ) - config_entry.add_to_hass(hass) - - # Set up the integration to make sure the config flow module is loaded. - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Unload the integration to prepare for the test. - with patch("homeassistant.components.tuya.async_unload_entry", return_value=True): - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Test check for integration not loaded - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == RESULT_CONN_ERROR - - # Load integration and enter options - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - hass.data[DOMAIN] = {TUYA_DATA: MockTuya()} - 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" - - # Test dev not found error - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID_FAKE1}"]}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": ERROR_DEV_NOT_FOUND} - - # Test dev type error - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID_FAKE2}"]}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": ERROR_DEV_NOT_CONFIG} - - # Test multi dev error - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_LIST_DEVICES: [f"climate-{CLIMATE_ID}", f"light-{LIGHT_ID}"]}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": ERROR_DEV_MULTI_TYPE} - - # Test climate options form - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_LIST_DEVICES: [f"climate-{CLIMATE_ID}"]} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "device" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - CONF_TEMP_DIVIDER: 10, - CONF_CURR_TEMP_DIVIDER: 5, - CONF_SET_TEMP_DIVIDED: False, - CONF_TEMP_STEP_OVERRIDE: STEP_HALVES, - CONF_MIN_TEMP: 12, - CONF_MAX_TEMP: 22, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - # Test light options form - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_LIST_DEVICES: [f"light-{LIGHT_ID}"]} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "device" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_SUPPORT_COLOR: True, - CONF_BRIGHTNESS_RANGE_MODE: 1, - CONF_MIN_KELVIN: 4000, - CONF_MAX_KELVIN: 5000, - CONF_TUYA_MAX_COLTEMP: 12000, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - # Test common options - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_DISCOVERY_INTERVAL: 100, - CONF_QUERY_INTERVAL: 50, - CONF_QUERY_DEVICE: LIGHT_ID, - }, - ) - - # Verify results - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - climate_options = config_entry.options[CLIMATE_ID] - assert climate_options[CONF_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - assert climate_options[CONF_TEMP_DIVIDER] == 10 - assert climate_options[CONF_CURR_TEMP_DIVIDER] == 5 - assert climate_options[CONF_SET_TEMP_DIVIDED] is False - assert climate_options[CONF_TEMP_STEP_OVERRIDE] == STEP_HALVES - assert climate_options[CONF_MIN_TEMP] == 12 - assert climate_options[CONF_MAX_TEMP] == 22 - - light_options = config_entry.options[LIGHT_ID] - assert light_options[CONF_SUPPORT_COLOR] is True - assert light_options[CONF_BRIGHTNESS_RANGE_MODE] == 1 - assert light_options[CONF_MIN_KELVIN] == 4000 - assert light_options[CONF_MAX_KELVIN] == 5000 - assert light_options[CONF_TUYA_MAX_COLTEMP] == 12000 - - assert config_entry.options[CONF_DISCOVERY_INTERVAL] == 100 - assert config_entry.options[CONF_QUERY_INTERVAL] == 50 - assert config_entry.options[CONF_QUERY_DEVICE] == LIGHT_ID + assert result["errors"]["base"] == "login_error" + assert result["description_placeholders"]["code"] == RESPONSE_ERROR["code"] + assert result["description_placeholders"]["msg"] == RESPONSE_ERROR["msg"] diff --git a/tests/components/twilio/test_init.py b/tests/components/twilio/test_init.py index 3529159eae1..8490f7541eb 100644 --- a/tests/components/twilio/test_init.py +++ b/tests/components/twilio/test_init.py @@ -5,7 +5,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import callback -async def test_config_flow_registers_webhook(hass, aiohttp_client): +async def test_config_flow_registers_webhook(hass, hass_client_no_auth): """Test setting up Twilio and sending webhook.""" await async_process_ha_core_config( hass, @@ -29,7 +29,7 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", data={"hello": "twilio"}) assert len(twilio_events) == 1 diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index d583cad86c3..4ba690dc444 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -381,6 +381,49 @@ async def test_remove_clients(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.client_2") +async def test_remove_client_but_keep_device_entry( + hass, aioclient_mock, mock_unifi_websocket +): + """Test that unifi entity base remove config entry id from a multi integration device registry entry.""" + client_1 = { + "essid": "ssid", + "hostname": "client_1", + "is_wired": False, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:01", + } + await setup_unifi_integration(hass, aioclient_mock, clients_response=[client_1]) + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id="other", + connections={("mac", "00:00:00:00:00:01")}, + ) + + entity_registry = er.async_get(hass) + other_entity = entity_registry.async_get_or_create( + TRACKER_DOMAIN, + "other", + "unique_id", + device_id=device_entry.id, + ) + assert len(device_entry.config_entries) == 2 + + mock_unifi_websocket( + data={ + "meta": {"message": MESSAGE_CLIENT_REMOVED}, + "data": [client_1], + } + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 0 + + device_entry = device_registry.async_get(other_entity.device_id) + assert len(device_entry.config_entries) == 1 + + async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocket): """Verify entities state reflect on controller becoming unavailable.""" client = { diff --git a/tests/components/unifi/test_services.py b/tests/components/unifi/test_services.py new file mode 100644 index 00000000000..388a33a4c64 --- /dev/null +++ b/tests/components/unifi/test_services.py @@ -0,0 +1,151 @@ +"""deCONZ service tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi.services import ( + SERVICE_REMOVE_CLIENTS, + UNIFI_SERVICES, + async_setup_services, + async_unload_services, +) + +from .test_controller import setup_unifi_integration + + +async def test_service_setup(hass): + """Verify service setup works.""" + assert UNIFI_SERVICES not in hass.data + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await async_setup_services(hass) + assert hass.data[UNIFI_SERVICES] is True + assert async_register.call_count == 1 + + +async def test_service_setup_already_registered(hass): + """Make sure that services are only registered once.""" + hass.data[UNIFI_SERVICES] = True + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await async_setup_services(hass) + async_register.assert_not_called() + + +async def test_service_unload(hass): + """Verify service unload works.""" + hass.data[UNIFI_SERVICES] = True + with patch( + "homeassistant.core.ServiceRegistry.async_remove", return_value=Mock(True) + ) as async_remove: + await async_unload_services(hass) + assert hass.data[UNIFI_SERVICES] is False + assert async_remove.call_count == 1 + + +async def test_service_unload_not_registered(hass): + """Make sure that services can only be unloaded once.""" + with patch( + "homeassistant.core.ServiceRegistry.async_remove", return_value=Mock(True) + ) as async_remove: + await async_unload_services(hass) + assert UNIFI_SERVICES not in hass.data + async_remove.assert_not_called() + + +async def test_remove_clients(hass, aioclient_mock): + """Verify removing different variations of clients work.""" + clients = [ + { + "first_seen": 100, + "last_seen": 500, + "mac": "00:00:00:00:00:01", + }, + { + "first_seen": 100, + "last_seen": 1100, + "mac": "00:00:00:00:00:02", + }, + { + "first_seen": 100, + "last_seen": 500, + "fixed_ip": "1.2.3.4", + "mac": "00:00:00:00:00:03", + }, + { + "first_seen": 100, + "last_seen": 500, + "hostname": "hostname", + "mac": "00:00:00:00:00:04", + }, + { + "first_seen": 100, + "last_seen": 500, + "name": "name", + "mac": "00:00:00:00:00:05", + }, + ] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_all_response=clients + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.mock_calls[0][2] == { + "cmd": "forget-sta", + "macs": ["00:00:00:00:00:01"], + } + + +async def test_remove_clients_controller_unavailable(hass, aioclient_mock): + """Verify no call is made if controller is unavailable.""" + clients = [ + { + "first_seen": 100, + "last_seen": 500, + "mac": "00:00:00:00:00:01", + } + ] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_all_response=clients + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + controller.available = False + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.call_count == 0 + + +async def test_remove_clients_no_call_on_empty_list(hass, aioclient_mock): + """Verify no call is made if no fitting client has been added to the list.""" + clients = [ + { + "first_seen": 100, + "last_seen": 1100, + "mac": "00:00:00:00:00:01", + } + ] + config_entry = await setup_unifi_integration( + hass, aioclient_mock, clients_all_response=clients + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", + ) + + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.call_count == 0 diff --git a/tests/components/upnp/__init__.py b/tests/components/upnp/__init__.py index 4fcc4167e5b..54ceff6eb1d 100644 --- a/tests/components/upnp/__init__.py +++ b/tests/components/upnp/__init__.py @@ -1 +1 @@ -"""Tests for the IGD component.""" +"""Tests for the upnp component.""" diff --git a/tests/components/upnp/common.py b/tests/components/upnp/common.py deleted file mode 100644 index 4dd0fd4083d..00000000000 --- a/tests/components/upnp/common.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Common for upnp.""" - -from urllib.parse import urlparse - -from homeassistant.components import ssdp - -TEST_UDN = "uuid:device" -TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" -TEST_USN = f"{TEST_UDN}::{TEST_ST}" -TEST_LOCATION = "http://192.168.1.1/desc.xml" -TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname -TEST_FRIENDLY_NAME = "friendly name" -TEST_DISCOVERY = { - ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, - ssdp.ATTR_SSDP_ST: TEST_ST, - ssdp.ATTR_SSDP_USN: TEST_USN, - ssdp.ATTR_UPNP_UDN: TEST_UDN, - "usn": TEST_USN, - "location": TEST_LOCATION, - "_host": TEST_HOSTNAME, - "_udn": TEST_UDN, - "friendlyName": TEST_FRIENDLY_NAME, -} diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py new file mode 100644 index 00000000000..5af99e9ac2d --- /dev/null +++ b/tests/components/upnp/conftest.py @@ -0,0 +1,187 @@ +"""Configuration for SSDP tests.""" +from typing import Any, Mapping +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from homeassistant.components import ssdp +from homeassistant.components.upnp.const import ( + BYTES_RECEIVED, + BYTES_SENT, + PACKETS_RECEIVED, + PACKETS_SENT, + ROUTER_IP, + ROUTER_UPTIME, + TIMESTAMP, + WAN_STATUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +TEST_UDN = "uuid:device" +TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" +TEST_USN = f"{TEST_UDN}::{TEST_ST}" +TEST_LOCATION = "http://192.168.1.1/desc.xml" +TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname +TEST_FRIENDLY_NAME = "friendly name" +TEST_DISCOVERY = { + ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, + ssdp.ATTR_SSDP_ST: TEST_ST, + ssdp.ATTR_SSDP_USN: TEST_USN, + ssdp.ATTR_UPNP_UDN: TEST_UDN, + "usn": TEST_USN, + "location": TEST_LOCATION, + "_host": TEST_HOSTNAME, + "_udn": TEST_UDN, + "friendlyName": TEST_FRIENDLY_NAME, +} + + +class MockDevice: + """Mock device for Device.""" + + def __init__(self, hass: HomeAssistant, udn: str) -> None: + """Initialize mock device.""" + self.hass = hass + self._udn = udn + self.traffic_times_polled = 0 + self.status_times_polled = 0 + + @classmethod + async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": + """Return self.""" + return cls(hass, TEST_UDN) + + async def async_ssdp_callback( + self, headers: Mapping[str, Any], change: ssdp.SsdpChange + ) -> None: + """SSDP callback, update if needed.""" + pass + + @property + def udn(self) -> str: + """Get the UDN.""" + return self._udn + + @property + def manufacturer(self) -> str: + """Get manufacturer.""" + return "mock-manufacturer" + + @property + def name(self) -> str: + """Get name.""" + return "mock-name" + + @property + def model_name(self) -> str: + """Get the model name.""" + return "mock-model-name" + + @property + def device_type(self) -> str: + """Get the device type.""" + return "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + + @property + def usn(self) -> str: + """Get the USN.""" + return f"{self.udn}::{self.device_type}" + + @property + def unique_id(self) -> str: + """Get the unique id.""" + return self.usn + + @property + def hostname(self) -> str: + """Get the hostname.""" + return "mock-hostname" + + async def async_get_traffic_data(self) -> Mapping[str, Any]: + """Get traffic data.""" + self.traffic_times_polled += 1 + return { + TIMESTAMP: dt.utcnow(), + BYTES_RECEIVED: 0, + BYTES_SENT: 0, + PACKETS_RECEIVED: 0, + PACKETS_SENT: 0, + } + + async def async_get_status(self) -> Mapping[str, Any]: + """Get connection status, uptime, and external IP.""" + self.status_times_polled += 1 + return { + WAN_STATUS: "Connected", + ROUTER_UPTIME: 0, + ROUTER_IP: "192.168.0.1", + } + + +@pytest.fixture(autouse=True) +def mock_upnp_device(): + """Mock homeassistant.components.upnp.Device.""" + with patch( + "homeassistant.components.upnp.Device", new=MockDevice + ) as mock_async_create_device: + yield mock_async_create_device + + +@pytest.fixture +def mock_setup_entry(): + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.upnp.async_setup_entry", + return_value=AsyncMock(True), + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(autouse=True) +async def silent_ssdp_scanner(hass): + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with patch( + "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" + ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( + "homeassistant.components.ssdp.Scanner.async_scan" + ): + yield + + +@pytest.fixture +async def ssdp_instant_discovery(): + """Instance discovery.""" + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Immediately do callback.""" + await callback(TEST_DISCOVERY, ssdp.SsdpChange.ALIVE) + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ) as mock_register, patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[TEST_DISCOVERY], + ) as mock_get_info: + yield (mock_register, mock_get_info) + + +@pytest.fixture +async def ssdp_no_discovery(): + """No discovery.""" + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Don't do callback.""" + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ) as mock_register, patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[], + ) as mock_get_info: + yield (mock_register, mock_get_info) diff --git a/tests/components/upnp/mock_ssdp_scanner.py b/tests/components/upnp/mock_ssdp_scanner.py deleted file mode 100644 index 39f9a801bb6..00000000000 --- a/tests/components/upnp/mock_ssdp_scanner.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Mock ssdp.Scanner.""" -from __future__ import annotations - -from typing import Any -from unittest.mock import patch - -import pytest - -from homeassistant.components import ssdp -from homeassistant.core import callback - - -class MockSsdpDescriptionManager(ssdp.DescriptionManager): - """Mocked ssdp DescriptionManager.""" - - async def fetch_description( - self, xml_location: str | None - ) -> None | dict[str, str]: - """Fetch the location or get it from the cache.""" - if xml_location is None: - return None - return {} - - -class MockSsdpScanner(ssdp.Scanner): - """Mocked ssdp Scanner.""" - - @callback - def async_stop(self, *_: Any) -> None: - """Stop the scanner.""" - # Do nothing. - - async def async_start(self) -> None: - """Start the scanner.""" - self.description_manager = MockSsdpDescriptionManager(self.hass) - - @callback - def async_scan(self, *_: Any) -> None: - """Scan for new entries.""" - # Do nothing. - - -@pytest.fixture -def mock_ssdp_scanner(): - """Mock ssdp Scanner.""" - with patch( - "homeassistant.components.ssdp.Scanner", new=MockSsdpScanner - ) as mock_ssdp_scanner: - yield mock_ssdp_scanner diff --git a/tests/components/upnp/mock_upnp_device.py b/tests/components/upnp/mock_upnp_device.py deleted file mode 100644 index 42c9291f30f..00000000000 --- a/tests/components/upnp/mock_upnp_device.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Mock device for testing purposes.""" - -from typing import Any, Mapping -from unittest.mock import AsyncMock, patch - -import pytest - -from homeassistant.components.upnp.const import ( - BYTES_RECEIVED, - BYTES_SENT, - PACKETS_RECEIVED, - PACKETS_SENT, - TIMESTAMP, - UPTIME, - WANIP, - WANSTATUS, -) -from homeassistant.components.upnp.device import Device -from homeassistant.util import dt - -from .common import TEST_UDN - - -class MockDevice(Device): - """Mock device for Device.""" - - def __init__(self, udn: str) -> None: - """Initialize mock device.""" - igd_device = object() - mock_device_updater = AsyncMock() - super().__init__(igd_device, mock_device_updater) - self._udn = udn - self.traffic_times_polled = 0 - self.status_times_polled = 0 - - @classmethod - async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": - """Return self.""" - return cls(TEST_UDN) - - @property - def udn(self) -> str: - """Get the UDN.""" - return self._udn - - @property - def manufacturer(self) -> str: - """Get manufacturer.""" - return "mock-manufacturer" - - @property - def name(self) -> str: - """Get name.""" - return "mock-name" - - @property - def model_name(self) -> str: - """Get the model name.""" - return "mock-model-name" - - @property - def device_type(self) -> str: - """Get the device type.""" - return "urn:schemas-upnp-org:device:InternetGatewayDevice:1" - - @property - def hostname(self) -> str: - """Get the hostname.""" - return "mock-hostname" - - async def async_get_traffic_data(self) -> Mapping[str, Any]: - """Get traffic data.""" - self.traffic_times_polled += 1 - return { - TIMESTAMP: dt.utcnow(), - BYTES_RECEIVED: 0, - BYTES_SENT: 0, - PACKETS_RECEIVED: 0, - PACKETS_SENT: 0, - } - - async def async_get_status(self) -> Mapping[str, Any]: - """Get connection status, uptime, and external IP.""" - self.status_times_polled += 1 - return { - WANSTATUS: "Connected", - UPTIME: 0, - WANIP: "192.168.0.1", - } - - async def async_start(self) -> None: - """Start the device updater.""" - - async def async_stop(self) -> None: - """Stop the device updater.""" - - -@pytest.fixture -def mock_upnp_device(): - """Mock upnp Device.async_create_device.""" - with patch( - "homeassistant.components.upnp.Device", new=MockDevice - ) as mock_async_create_device: - yield mock_async_create_device diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 907fa709c84..fa315804917 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,7 +1,6 @@ """Test UPnP/IGD config flow.""" from datetime import timedelta -from unittest.mock import patch import pytest @@ -15,11 +14,10 @@ from homeassistant.components.upnp.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from homeassistant.core import CoreState, HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant from homeassistant.util import dt -from .common import ( +from .conftest import ( TEST_DISCOVERY, TEST_FRIENDLY_NAME, TEST_HOSTNAME, @@ -28,25 +26,15 @@ from .common import ( TEST_UDN, TEST_USN, ) -from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401 -from .mock_upnp_device import mock_upnp_device # noqa: F401 from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") -async def test_flow_ssdp_discovery( - hass: HomeAssistant, -): +@pytest.mark.usefixtures( + "ssdp_instant_discovery", "mock_setup_entry", "mock_get_source_ip" +) +async def test_flow_ssdp(hass: HomeAssistant): """Test config flow: discovered + configured through ssdp.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Discovered via step ssdp. result = await hass.config_entries.flow.async_init( DOMAIN, @@ -70,7 +58,7 @@ async def test_flow_ssdp_discovery( } -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("mock_get_source_ip") async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. @@ -88,7 +76,7 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): assert result["reason"] == "incomplete_discovery" -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("mock_get_source_ip") async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" # Existing entry. @@ -113,17 +101,11 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): assert result["reason"] == "discovery_ignored" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures( + "ssdp_instant_discovery", "mock_setup_entry", "mock_get_source_ip" +) async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Discovered via step user. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -145,17 +127,11 @@ async def test_flow_user(hass: HomeAssistant): } -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures( + "ssdp_instant_discovery", "mock_setup_entry", "mock_get_source_ip" +) async def test_flow_import(hass: HomeAssistant): """Test config flow: configured through configuration.yaml.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Discovered via step import. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} @@ -169,7 +145,7 @@ async def test_flow_import(hass: HomeAssistant): } -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_flow_import_already_configured(hass: HomeAssistant): """Test config flow: configured through configuration.yaml, but existing config entry.""" # Existing entry. @@ -193,37 +169,20 @@ async def test_flow_import_already_configured(hass: HomeAssistant): assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_ssdp_scanner") +@pytest.mark.usefixtures("ssdp_no_discovery", "mock_get_source_ip") async def test_flow_import_no_devices_found(hass: HomeAssistant): """Test config flow: no devices found, configured through configuration.yaml.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache.clear() - # Discovered via step import. - with patch( - "homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", new=0.0 - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "no_devices_found" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_options_flow(hass: HomeAssistant): """Test options flow.""" - # Ensure we have a ssdp Scanner. - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Set up config entry. config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 9ccdbf02f4b..6b3d2a5187f 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -3,25 +3,23 @@ from __future__ import annotations import pytest -from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DOMAIN, ) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_DISCOVERY, TEST_ST, TEST_UDN -from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401 -from .mock_upnp_device import mock_upnp_device # noqa: F401 +from .conftest import TEST_ST, TEST_UDN from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_async_setup_entry_default(hass: HomeAssistant): """Test async_setup_entry.""" + entry = MockConfigEntry( domain=DOMAIN, data={ @@ -34,12 +32,6 @@ async def test_async_setup_entry_default(hass: HomeAssistant): await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - # Device is discovered. - ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN] - ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY - # Speed up callback in ssdp.async_register_callback. - hass.state = CoreState.not_running - # Load config_entry. entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) is True diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index b09dad9ebe4..7d620b45984 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -176,6 +176,45 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +async def test_most_targeted_matcher_wins(hass, hass_ws_client): + """Test that the most targeted matcher is used.""" + new_usb = [ + {"domain": "less", "vid": "3039", "pid": "3039"}, + {"domain": "more", "vid": "3039", "pid": "3039", "description": "*2652*"}, + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "more" + + async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass, hass_ws_client ): diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 5627daec7f8..91b03f7a1bb 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.utility_meter.const import ( @@ -31,6 +31,7 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import State from homeassistant.setup import async_setup_component @@ -64,6 +65,8 @@ async def test_state(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + entity_id = config[DOMAIN]["energy_bill"]["source"] hass.states.async_set( entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} @@ -74,16 +77,19 @@ async def test_state(hass): assert state is not None assert state.state == "0" assert state.attributes.get("status") == COLLECTING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_midpeak") assert state is not None assert state.state == "0" assert state.attributes.get("status") == PAUSED + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_offpeak") assert state is not None assert state.state == "0" assert state.attributes.get("status") == PAUSED + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -187,6 +193,49 @@ async def test_state(hass): assert state.state == "0.123" +async def test_init(hass): + """Test utility sensor state initializtion.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_onpeak") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("sensor.energy_bill_offpeak") + assert state is not None + assert state.state == STATE_UNKNOWN + + entity_id = config[DOMAIN]["energy_bill"]["source"] + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_onpeak") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + + state = hass.states.get("sensor.energy_bill_offpeak") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + + async def test_device_class(hass): """Test utility device_class.""" config = { @@ -205,6 +254,8 @@ async def test_device_class(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + entity_id_energy = config[DOMAIN]["energy_meter"]["source"] hass.states.async_set( entity_id_energy, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} @@ -218,35 +269,13 @@ async def test_device_class(hass): state = hass.states.get("sensor.energy_meter") assert state is not None assert state.state == "0" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - - state = hass.states.get("sensor.gas_meter") - assert state is not None - assert state.state == "0" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - - hass.states.async_set( - entity_id_energy, 3, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} - ) - hass.states.async_set( - entity_id_gas, 3, {ATTR_UNIT_OF_MEASUREMENT: "some_archaic_unit"} - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.energy_meter") - assert state is not None - assert state.state == "1" assert state.attributes.get(ATTR_DEVICE_CLASS) == "energy" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.gas_meter") assert state is not None - assert state.state == "1" + assert state.state == "0" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "some_archaic_unit" @@ -272,6 +301,7 @@ async def test_restore_state(hass): attributes={ ATTR_STATUS: PAUSED, ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, ), State( @@ -280,6 +310,7 @@ async def test_restore_state(hass): attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, ), ], @@ -293,11 +324,13 @@ async def test_restore_state(hass): assert state.state == "3" assert state.attributes.get("status") == PAUSED assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "6" assert state.attributes.get("status") == COLLECTING assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR # utility_meter is loaded, now set sensors according to utility_meter: hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index f4a95f0fdf9..723b6664fd7 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -1,7 +1,8 @@ """Tests for the Velbus config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch import pytest +from velbusaio.exceptions import VelbusConnectionFailed from homeassistant import data_entry_flow from homeassistant.components.velbus import config_flow @@ -16,15 +17,15 @@ PORT_TCP = "127.0.1.0.1:3788" @pytest.fixture(name="controller_assert") def mock_controller_assert(): """Mock the velbus controller with an assert.""" - with patch("velbus.Controller", side_effect=Exception()): + with patch("velbusaio.controller.Velbus", side_effect=VelbusConnectionFailed()): yield @pytest.fixture(name="controller") def mock_controller(): """Mock a successful velbus controller.""" - controller = Mock() - with patch("velbus.Controller", return_value=controller): + controller = AsyncMock() + with patch("velbusaio.controller.Velbus", return_value=controller): yield controller diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index c137f112976..7a030ade53f 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -38,6 +38,7 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, ) +from homeassistant.components.media_player.const import ATTR_INPUT_SOURCE_LIST from homeassistant.components.vizio import validate_apps from homeassistant.components.vizio.const import ( CONF_ADDITIONAL_CONFIGS, @@ -102,8 +103,8 @@ def _get_ha_power_state(vizio_power_state: bool | None) -> str: def _assert_sources_and_volume(attr: dict[str, Any], vizio_device_class: str) -> None: """Assert source list, source, and volume level based on attr dict and device class.""" - assert attr["source_list"] == INPUT_LIST - assert attr["source"] == CURRENT_INPUT + assert attr[ATTR_INPUT_SOURCE_LIST] == INPUT_LIST + assert attr[ATTR_INPUT_SOURCE] == CURRENT_INPUT assert ( attr["volume_level"] == float(int(MAX_VOLUME[vizio_device_class] / 2)) @@ -236,7 +237,7 @@ def _assert_source_list_with_apps( if app_to_remove in list_to_test: list_to_test.remove(app_to_remove) - assert attr["source_list"] == list_to_test + assert attr[ATTR_INPUT_SOURCE_LIST] == list_to_test async def _test_service( @@ -533,8 +534,8 @@ async def test_setup_with_apps( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) - assert CURRENT_APP in attr["source_list"] - assert attr["source"] == CURRENT_APP + assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == CURRENT_APP assert attr["app_name"] == CURRENT_APP assert "app_id" not in attr @@ -561,8 +562,8 @@ async def test_setup_with_apps_include( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + [CURRENT_APP]), attr) - assert CURRENT_APP in attr["source_list"] - assert attr["source"] == CURRENT_APP + assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == CURRENT_APP assert attr["app_name"] == CURRENT_APP assert "app_id" not in attr @@ -579,8 +580,8 @@ async def test_setup_with_apps_exclude( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + [CURRENT_APP]), attr) - assert CURRENT_APP in attr["source_list"] - assert attr["source"] == CURRENT_APP + assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == CURRENT_APP assert attr["app_name"] == CURRENT_APP assert "app_id" not in attr @@ -598,7 +599,7 @@ async def test_setup_with_apps_additional_apps_config( ADDITIONAL_APP_CONFIG["config"], ): attr = hass.states.get(ENTITY_ID).attributes - assert attr["source_list"].count(CURRENT_APP) == 1 + assert attr[ATTR_INPUT_SOURCE_LIST].count(CURRENT_APP) == 1 _assert_source_list_with_apps( list( INPUT_LIST_WITH_APPS @@ -613,8 +614,8 @@ async def test_setup_with_apps_additional_apps_config( ), attr, ) - assert ADDITIONAL_APP_CONFIG["name"] in attr["source_list"] - assert attr["source"] == ADDITIONAL_APP_CONFIG["name"] + assert ADDITIONAL_APP_CONFIG["name"] in attr[ATTR_INPUT_SOURCE_LIST] + assert attr[ATTR_INPUT_SOURCE] == ADDITIONAL_APP_CONFIG["name"] assert attr["app_name"] == ADDITIONAL_APP_CONFIG["name"] assert "app_id" not in attr @@ -673,7 +674,7 @@ async def test_setup_with_unknown_app_config( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) - assert attr["source"] == UNKNOWN_APP + assert attr[ATTR_INPUT_SOURCE] == UNKNOWN_APP assert attr["app_name"] == UNKNOWN_APP assert attr["app_id"] == UNKNOWN_APP_CONFIG @@ -690,7 +691,7 @@ async def test_setup_with_no_running_app( ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) - assert attr["source"] == "CAST" + assert attr[ATTR_INPUT_SOURCE] == "CAST" assert "app_id" not in attr assert "app_name" not in attr @@ -735,7 +736,7 @@ async def test_apps_update( ): # Check source list, remove TV inputs, and verify that the integration is # using the default APPS list - sources = hass.states.get(ENTITY_ID).attributes["source_list"] + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) assert len(apps) == len(APPS) @@ -747,6 +748,6 @@ async def test_apps_update( await hass.async_block_till_done() # Check source list, remove TV inputs, and verify that the integration is # now using the APP_LIST list - sources = hass.states.get(ENTITY_ID).attributes["source_list"] + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) assert len(apps) == len(APP_LIST) diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index e1dbda1dd04..bacffe8e6af 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -39,12 +39,12 @@ class TestVultrSensorSetup(unittest.TestCase): { CONF_NAME: vultr.DEFAULT_NAME, CONF_SUBSCRIPTION: "576965", - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, }, { CONF_NAME: "Server {}", CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, }, { CONF_NAME: "VPS Charges", @@ -126,7 +126,7 @@ class TestVultrSensorSetup(unittest.TestCase): vultr.PLATFORM_SCHEMA( { CONF_PLATFORM: base_vultr.DOMAIN, - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, } ) with pytest.raises(vol.Invalid): # Bad monitored_conditions @@ -154,7 +154,9 @@ class TestVultrSensorSetup(unittest.TestCase): base_vultr.setup(self.hass, VALID_CONFIG) bad_conf = { - CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + CONF_NAME: "Vultr {} {}", + CONF_SUBSCRIPTION: "", + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, } # No subs at all no_sub_setup = vultr.setup_platform( diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index 6b5a05a3486..fba322182c9 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -1,11 +1,10 @@ """Test the Wallbox config flow.""" import json -from unittest.mock import patch import requests_mock from homeassistant import config_entries, data_entry_flow -from homeassistant.components.wallbox import InvalidAuth, config_flow +from homeassistant.components.wallbox import config_flow from homeassistant.components.wallbox.const import DOMAIN from homeassistant.core import HomeAssistant @@ -24,29 +23,6 @@ async def test_show_set_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -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( - "homeassistant.components.wallbox.config_flow.WallboxHub.async_authenticate", - side_effect=InvalidAuth, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "station": "12345", - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} - - async def test_form_cannot_authenticate(hass): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -59,19 +35,6 @@ async def test_form_cannot_authenticate(hass): text='{"jwt":"fakekeyhere","user_id":12345,"ttl":145656758,"error":false,"status":200}', status_code=403, ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - text='{"Temperature": 100, "Location": "Toronto", "Datetime": "2020-07-23", "Units": "Celsius"}', - status_code=403, - ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "station": "12345", - "username": "test-username", - "password": "test-password", - }, - ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/watttime/__init__.py b/tests/components/watttime/__init__.py new file mode 100644 index 00000000000..6e01f28b518 --- /dev/null +++ b/tests/components/watttime/__init__.py @@ -0,0 +1 @@ +"""Tests for the WattTime integration.""" diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py new file mode 100644 index 00000000000..efee2429d59 --- /dev/null +++ b/tests/components/watttime/test_config_flow.py @@ -0,0 +1,263 @@ +"""Test the WattTime config flow.""" +from unittest.mock import AsyncMock, patch + +from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.watttime.config_flow import ( + CONF_LOCATION_TYPE, + LOCATION_TYPE_COORDINATES, + LOCATION_TYPE_HOME, +) +from homeassistant.components.watttime.const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DOMAIN, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="client") +def client_fixture(get_grid_region): + """Define a fixture for an aiowatttime client.""" + client = AsyncMock(return_value=None) + client.emissions.async_get_grid_region = get_grid_region + return client + + +@pytest.fixture(name="client_login") +def client_login_fixture(client): + """Define a fixture for patching the aiowatttime coroutine to get a client.""" + with patch("homeassistant.components.watttime.config_flow.Client.async_login") as m: + m.return_value = client + yield m + + +@pytest.fixture(name="get_grid_region") +def get_grid_region_fixture(): + """Define a fixture for getting grid region data.""" + return AsyncMock(return_value={"abbrev": "AUTH_1", "id": 1, "name": "Authority 1"}) + + +async def test_duplicate_error(hass: HomeAssistant, client_login): + """Test that errors are shown when duplicate entries are added.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="32.87336, -117.22743", + data={ + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_show_form_coordinates(hass: HomeAssistant, client_login) -> None: + """Test showing the form to input custom latitude/longitude.""" + 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_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "coordinates" + assert result["errors"] is None + + +async def test_show_form_user(hass: HomeAssistant) -> None: + """Test showing the form to select the authentication type.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + +@pytest.mark.parametrize( + "get_grid_region", [AsyncMock(side_effect=CoordinatesNotFoundError)] +) +async def test_step_coordinates_unknown_coordinates( + hass: HomeAssistant, client_login +) -> None: + """Test that providing coordinates with no data is handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LATITUDE: "0", CONF_LONGITUDE: "0"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"latitude": "unknown_coordinates"} + + +@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)]) +async def test_step_coordinates_unknown_error( + hass: HomeAssistant, client_login +) -> None: + """Test that providing coordinates with no data is handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_step_login_coordinates(hass: HomeAssistant, client_login) -> None: + """Test a full login flow (inputting custom coordinates).""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "51.528308, -0.3817765" + assert result["data"] == { + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + CONF_BALANCING_AUTHORITY: "Authority 1", + CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + } + + +async def test_step_user_home(hass: HomeAssistant, client_login) -> None: + """Test a full login flow (selecting the home location).""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "32.87336, -117.22743" + assert result["data"] == { + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_BALANCING_AUTHORITY: "Authority 1", + CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + } + + +async def test_step_user_invalid_credentials(hass: HomeAssistant) -> None: + """Test that invalid credentials are handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.config_flow.Client.async_login", + AsyncMock(side_effect=InvalidCredentialsError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"username": "invalid_auth"} + + +@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)]) +async def test_step_user_unknown_error(hass: HomeAssistant, client_login) -> None: + """Test that an unknown error during the login step is handled.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.watttime.config_flow.Client.async_login", + AsyncMock(side_effect=Exception), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index ae70460de5d..2deac022b1e 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -17,7 +17,7 @@ async def setup_http(hass): await hass.async_block_till_done() -async def test_webhook_json(hass, aiohttp_client): +async def test_webhook_json(hass, hass_client_no_auth): """Test triggering with a JSON webhook.""" events = [] @@ -46,7 +46,7 @@ async def test_webhook_json(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/json_webhook", json={"hello": "world"}) await hass.async_block_till_done() @@ -56,7 +56,7 @@ async def test_webhook_json(hass, aiohttp_client): assert events[0].data["id"] == 0 -async def test_webhook_post(hass, aiohttp_client): +async def test_webhook_post(hass, hass_client_no_auth): """Test triggering with a POST webhook.""" events = [] @@ -82,7 +82,7 @@ async def test_webhook_post(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/post_webhook", data={"hello": "world"}) await hass.async_block_till_done() @@ -91,7 +91,7 @@ async def test_webhook_post(hass, aiohttp_client): assert events[0].data["hello"] == "yo world" -async def test_webhook_query(hass, aiohttp_client): +async def test_webhook_query(hass, hass_client_no_auth): """Test triggering with a query POST webhook.""" events = [] @@ -117,7 +117,7 @@ async def test_webhook_query(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/query_webhook?hello=world") await hass.async_block_till_done() @@ -126,7 +126,7 @@ async def test_webhook_query(hass, aiohttp_client): assert events[0].data["hello"] == "yo world" -async def test_webhook_reload(hass, aiohttp_client): +async def test_webhook_reload(hass, hass_client_no_auth): """Test reloading a webhook.""" events = [] @@ -152,7 +152,7 @@ async def test_webhook_reload(hass, aiohttp_client): ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() await client.post("/api/webhook/post_webhook", data={"hello": "world"}) await hass.async_block_till_done() diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index 016fdfebc11..53569c3fa6a 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -13,12 +13,12 @@ async def websocket_client(hass, hass_ws_client): @pytest.fixture -async def no_auth_websocket_client(hass, aiohttp_client): +async def no_auth_websocket_client(hass, hass_client_no_auth): """Websocket connection that requires authentication.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() ws = await client.ws_connect(URL) auth_ok = await ws.receive_json() diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index c0313794783..a57faf4a895 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -121,14 +121,14 @@ async def test_auth_active_with_token( assert auth_msg["type"] == TYPE_AUTH_OK -async def test_auth_active_user_inactive(hass, aiohttp_client, hass_access_token): +async def test_auth_active_user_inactive(hass, hass_client_no_auth, hass_access_token): """Test authenticating with a token.""" refresh_token = await hass.auth.async_validate_access_token(hass_access_token) refresh_token.user.is_active = False assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -140,12 +140,12 @@ async def test_auth_active_user_inactive(hass, aiohttp_client, hass_access_token assert auth_msg["type"] == TYPE_AUTH_INVALID -async def test_auth_active_with_password_not_allow(hass, aiohttp_client): +async def test_auth_active_with_password_not_allow(hass, hass_client_no_auth): """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -157,12 +157,14 @@ async def test_auth_active_with_password_not_allow(hass, aiohttp_client): assert auth_msg["type"] == TYPE_AUTH_INVALID -async def test_auth_legacy_support_with_password(hass, aiohttp_client, legacy_auth): +async def test_auth_legacy_support_with_password( + hass, hass_client_no_auth, legacy_auth +): """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -174,12 +176,12 @@ async def test_auth_legacy_support_with_password(hass, aiohttp_client, legacy_au assert auth_msg["type"] == TYPE_AUTH_INVALID -async def test_auth_with_invalid_token(hass, aiohttp_client): +async def test_auth_with_invalid_token(hass, hass_client_no_auth): """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index bb74bbe8ca8..447f38f9a9c 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -459,12 +459,14 @@ async def test_ping(websocket_client): assert msg["type"] == "pong" -async def test_call_service_context_with_user(hass, aiohttp_client, hass_access_token): +async def test_call_service_context_with_user( + hass, hass_client_no_auth, hass_access_token +): """Test that the user is set in the service call context.""" assert await async_setup_component(hass, "websocket_api", {}) calls = async_mock_service(hass, "domain_test", "test_service") - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() @@ -920,12 +922,14 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_1": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -940,8 +944,9 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -960,12 +965,14 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_1": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -999,8 +1006,9 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } diff --git a/tests/components/websocket_api/test_sensor.py b/tests/components/websocket_api/test_sensor.py index 429876cd365..b7565de650b 100644 --- a/tests/components/websocket_api/test_sensor.py +++ b/tests/components/websocket_api/test_sensor.py @@ -7,14 +7,14 @@ from homeassistant.components.websocket_api.http import URL from .test_auth import test_auth_active_with_token -async def test_websocket_api(hass, aiohttp_client, hass_access_token, legacy_auth): +async def test_websocket_api(hass, hass_client_no_auth, hass_access_token, legacy_auth): """Test API streams.""" await async_setup_component( hass, "sensor", {"sensor": {"platform": "websocket_api"}} ) await hass.async_block_till_done() - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() ws = await client.ws_connect(URL) auth_ok = await ws.receive_json() diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 00000000000..3f50518b4ad --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -0,0 +1,23 @@ +"""Tests for the Whirlpool Sixth Sense integration.""" +from homeassistant.components.whirlpool.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Whirlpool integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "nobody", + CONF_PASSWORD: "qwerty", + }, + ) + + 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/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py new file mode 100644 index 00000000000..e3919c118e2 --- /dev/null +++ b/tests/components/whirlpool/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for the Whirlpool Sixth Sense integration tests.""" +from unittest import mock +from unittest.mock import AsyncMock + +import pytest +import whirlpool + +MOCK_SAID = "said1" + + +@pytest.fixture(name="mock_auth_api") +def fixture_mock_auth_api(): + """Set up air conditioner Auth fixture.""" + with mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth: + mock_auth.return_value.do_auth = AsyncMock() + mock_auth.return_value.is_access_token_valid.return_value = True + mock_auth.return_value.get_said_list.return_value = [MOCK_SAID] + yield mock_auth + + +@pytest.fixture(name="mock_aircon_api", autouse=True) +def fixture_mock_aircon_api(mock_auth_api): + """Set up air conditioner API fixture.""" + with mock.patch( + "homeassistant.components.whirlpool.climate.Aircon" + ) as mock_aircon_api: + mock_aircon_api.return_value.connect = AsyncMock() + mock_aircon_api.return_value.fetch_name = AsyncMock(return_value="TestZone") + mock_aircon_api.return_value.said = MOCK_SAID + mock_aircon_api.return_value.get_online.return_value = True + mock_aircon_api.return_value.get_power_on.return_value = True + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Cool + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Auto + ) + mock_aircon_api.return_value.get_current_temp.return_value = 15 + mock_aircon_api.return_value.get_temp.return_value = 20 + mock_aircon_api.return_value.get_current_humidity.return_value = 80 + mock_aircon_api.return_value.get_humidity.return_value = 50 + mock_aircon_api.return_value.get_h_louver_swing.return_value = True + yield mock_aircon_api diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py new file mode 100644 index 00000000000..77009607947 --- /dev/null +++ b/tests/components/whirlpool/test_climate.py @@ -0,0 +1,364 @@ +"""Test the Whirlpool Sixth Sense climate domain.""" +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import whirlpool + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_STEP, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_HORIZONTAL, + SWING_OFF, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def update_ac_state(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Simulate an update trigger from the API.""" + update_ha_state_cb = mock_aircon_api.call_args.args[2] + update_ha_state_cb() + await hass.async_block_till_done() + return hass.states.get("climate.said1") + + +async def test_no_appliances(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test the setup of the climate entities when there are no appliances available.""" + mock_auth_api.return_value.get_said_list.return_value = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + +async def test_name_fallback_on_exception( + hass: HomeAssistant, mock_aircon_api: MagicMock +): + """Test name property.""" + mock_aircon_api.return_value.fetch_name = AsyncMock( + side_effect=aiohttp.ClientError() + ) + + await init_integration(hass) + state = hass.states.get("climate.said1") + assert state.attributes[ATTR_FRIENDLY_NAME] == "said1" + + +async def test_static_attributes(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test static climate attributes.""" + await init_integration(hass) + + entry = er.async_get(hass).async_get("climate.said1") + assert entry + assert entry.unique_id == "said1" + + state = hass.states.get("climate.said1") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == HVAC_MODE_COOL + + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == "TestZone" + + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_SWING_MODE + ) + assert attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_OFF, + ] + assert attributes[ATTR_FAN_MODES] == [ + FAN_AUTO, + FAN_HIGH, + FAN_MEDIUM, + FAN_LOW, + FAN_OFF, + ] + assert attributes[ATTR_SWING_MODES] == [SWING_HORIZONTAL, SWING_OFF] + assert attributes[ATTR_TARGET_TEMP_STEP] == 1 + assert attributes[ATTR_MIN_TEMP] == 16 + assert attributes[ATTR_MAX_TEMP] == 30 + + +async def test_dynamic_attributes(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test dynamic attributes.""" + await init_integration(hass) + + state = hass.states.get("climate.said1") + assert state is not None + assert state.state == HVAC_MODE_COOL + + mock_aircon_api.return_value.get_power_on.return_value = False + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_OFF + + mock_aircon_api.return_value.get_online.return_value = False + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == STATE_UNAVAILABLE + + mock_aircon_api.return_value.get_power_on.return_value = True + mock_aircon_api.return_value.get_online.return_value = True + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_COOL + + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Heat + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_HEAT + + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Fan + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_FAN_ONLY + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Auto + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == HVAC_MODE_AUTO + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Low + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_LOW + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Medium + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.High + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Off + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF + + mock_aircon_api.return_value.get_current_temp.return_value = 15 + mock_aircon_api.return_value.get_temp.return_value = 20 + mock_aircon_api.return_value.get_current_humidity.return_value = 80 + mock_aircon_api.return_value.get_h_louver_swing.return_value = True + attributes = (await update_ac_state(hass, mock_aircon_api)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 + assert attributes[ATTR_TEMPERATURE] == 20 + assert attributes[ATTR_CURRENT_HUMIDITY] == 80 + assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL + + mock_aircon_api.return_value.get_current_temp.return_value = 16 + mock_aircon_api.return_value.get_temp.return_value = 21 + mock_aircon_api.return_value.get_current_humidity.return_value = 70 + mock_aircon_api.return_value.get_h_louver_swing.return_value = False + attributes = (await update_ac_state(hass, mock_aircon_api)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 + assert attributes[ATTR_TEMPERATURE] == 21 + assert attributes[ATTR_CURRENT_HUMIDITY] == 70 + assert attributes[ATTR_SWING_MODE] == SWING_OFF + + +async def test_service_calls(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test controlling the entity through service calls.""" + await init_integration(hass) + mock_aircon_api.return_value.set_power_on = AsyncMock() + mock_aircon_api.return_value.set_mode = AsyncMock() + mock_aircon_api.return_value.set_temp = AsyncMock() + mock_aircon_api.return_value.set_humidity = AsyncMock() + mock_aircon_api.return_value.set_mode = AsyncMock() + mock_aircon_api.return_value.set_fanspeed = AsyncMock() + mock_aircon_api.return_value.set_h_louver_swing = AsyncMock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "climate.said1"}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(False) + + mock_aircon_api.return_value.set_power_on.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "climate.said1"}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(True) + + mock_aircon_api.return_value.set_power_on.reset_mock() + mock_aircon_api.return_value.get_power_on.return_value = False + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_COOL}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(True) + + mock_aircon_api.return_value.set_temp.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_TEMPERATURE: 15}, + blocking=True, + ) + mock_aircon_api.return_value.set_temp.assert_called_once_with(15) + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_COOL}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Cool + ) + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Heat + ) + + mock_aircon_api.return_value.set_mode.reset_mock() + # HVAC_MODE_DRY should be ignored + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_DRY}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_not_called() + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_FAN_ONLY}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Fan + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Auto + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Low + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_MEDIUM}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Medium + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + # FAN_MIDDLE should be ignored + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_MIDDLE}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_not_called() + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.High + ) + + mock_aircon_api.return_value.set_h_louver_swing.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_SWING_MODE: SWING_HORIZONTAL}, + blocking=True, + ) + mock_aircon_api.return_value.set_h_louver_swing.assert_called_with(True) + + mock_aircon_api.return_value.set_h_louver_swing.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_SWING_MODE: SWING_OFF}, + blocking=True, + ) + mock_aircon_api.return_value.set_h_louver_swing.assert_called_with(False) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py new file mode 100644 index 00000000000..6746e406a85 --- /dev/null +++ b/tests/components/whirlpool/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Whirlpool Sixth Sense config flow.""" +import asyncio +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries +from homeassistant.components.whirlpool.const import DOMAIN + + +async def test_form(hass): + """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["step_id"] == config_entries.SOURCE_USER + + with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( + "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", + return_value=True, + ), patch( + "homeassistant.components.whirlpool.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", + }, + ) + 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", + } + assert len(mock_setup_entry.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("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( + "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + 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( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=aiohttp.ClientConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_auth_timeout(hass): + """Test we handle auth timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_generic_auth_exception(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py new file mode 100644 index 00000000000..00fc27ddc63 --- /dev/null +++ b/tests/components/whirlpool/test_init.py @@ -0,0 +1,49 @@ +"""Test the Whirlpool Sixth Sense init.""" +from unittest.mock import AsyncMock, MagicMock + +import aiohttp + +from homeassistant.components.whirlpool.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.components.whirlpool import init_integration + + +async def test_setup(hass: HomeAssistant): + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + +async def test_setup_http_exception(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test setup with an http exception.""" + mock_auth_api.return_value.do_auth = AsyncMock( + side_effect=aiohttp.ClientConnectionError() + ) + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test setup with failed auth.""" + mock_auth_api.return_value.do_auth = AsyncMock() + mock_auth_api.return_value.is_access_token_valid.return_value = False + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry(hass: HomeAssistant): + """Test successful unload of entry.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/wilight/test_init.py b/tests/components/wilight/test_init.py index 24efdaaa8e1..5aadce3caea 100644 --- a/tests/components/wilight/test_init.py +++ b/tests/components/wilight/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest import pywilight from pywilight.const import DOMAIN +import requests from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -41,7 +42,11 @@ def mock_dummy_device_from_host(): async def test_config_entry_not_ready(hass: HomeAssistant) -> None: """Test the WiLight configuration entry not ready.""" - entry = await setup_integration(hass) + with patch( + "pywilight.device_from_host", + side_effect=requests.exceptions.Timeout, + ): + entry = await setup_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index 9e6efeb37bf..847f482b6c5 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -103,13 +103,13 @@ class ComponentFactory: self, hass: HomeAssistant, api_class_mock: MagicMock, - aiohttp_client, + hass_client_no_auth, aioclient_mock: AiohttpClientMocker, ) -> None: """Initialize the object.""" self._hass = hass self._api_class_mock = api_class_mock - self._aiohttp_client = aiohttp_client + self._hass_client = hass_client_no_auth self._aioclient_mock = aioclient_mock self._client_id = None self._client_secret = None @@ -208,7 +208,7 @@ class ComponentFactory: ) # Simulate user being redirected from withings site. - client: TestClient = await self._aiohttp_client(self._hass.http.app) + client: TestClient = await self._hass_client() resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -259,7 +259,7 @@ class ComponentFactory: async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: """Call the webhook to notify of data changes.""" - client: TestClient = await self._aiohttp_client(self._hass.http.app) + client: TestClient = await self._hass_client() data_manager = get_data_manager_by_user_id(self._hass, user_id) resp = await client.post( diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index c95abc8addd..787a2ee4cb0 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -13,10 +13,12 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture() def component_factory( - hass: HomeAssistant, aiohttp_client, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, hass_client_no_auth, aioclient_mock: AiohttpClientMocker ): """Return a factory for initializing the withings component.""" with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi" ) as api_class_mock: - yield ComponentFactory(hass, api_class_mock, aiohttp_client, aioclient_mock) + yield ComponentFactory( + hass, api_class_mock, hass_client_no_auth, aioclient_mock + ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 618dd19f80b..83368ed3fa1 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -35,7 +35,7 @@ async def test_config_non_unique_profile(hass: HomeAssistant) -> None: async def test_config_reauth_profile( - hass: HomeAssistant, aiohttp_client, aioclient_mock, current_request_with_host + hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host ) -> None: """Test reauth an existing profile re-creates the config entry.""" hass_config = { @@ -81,7 +81,7 @@ async def test_config_reauth_profile( }, ) - client: TestClient = await aiohttp_client(hass.http.app) + client: TestClient = await hass_client_no_auth() resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index 7e2863a5861..794814c284f 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -23,7 +23,7 @@ async def test_abort_if_existing_entry(hass): async def test_full_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host + hass, hass_client_no_auth, aioclient_mock, current_request_with_host ): """Check full flow.""" assert await setup.async_setup_component( @@ -54,7 +54,7 @@ async def test_full_flow( f"&state={state}&scope={scope}" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 08900b1dfad..31d63bac158 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -14,6 +14,17 @@ from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def silent_ssdp_scanner(hass): + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with patch( + "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" + ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( + "homeassistant.components.ssdp.Scanner.async_scan" + ): + yield + + @pytest.fixture(autouse=True) def mock_setup_entry(): """Mock setting up a config entry.""" @@ -104,7 +115,9 @@ def mock_empty_discovery_information(): # User Flows -async def test_user_input_device_not_found(hass, mock_get_device_info_mc_exception): +async def test_user_input_device_not_found( + hass, mock_get_device_info_mc_exception, mock_get_source_ip +): """Test when user specifies a non-existing device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -120,7 +133,9 @@ async def test_user_input_device_not_found(hass, mock_get_device_info_mc_excepti assert result2["errors"] == {"base": "cannot_connect"} -async def test_user_input_non_yamaha_device_found(hass, mock_get_device_info_invalid): +async def test_user_input_non_yamaha_device_found( + hass, mock_get_device_info_invalid, mock_get_source_ip +): """Test when user specifies an existing device, which does not provide the musiccast API.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -136,7 +151,9 @@ async def test_user_input_non_yamaha_device_found(hass, mock_get_device_info_inv assert result2["errors"] == {"base": "no_musiccast_device"} -async def test_user_input_device_already_existing(hass, mock_get_device_info_valid): +async def test_user_input_device_already_existing( + hass, mock_get_device_info_valid, mock_get_source_ip +): """Test when user specifies an existing device.""" mock_entry = MockConfigEntry( domain=DOMAIN, @@ -158,7 +175,9 @@ async def test_user_input_device_already_existing(hass, mock_get_device_info_val assert result2["reason"] == "already_configured" -async def test_user_input_unknown_error(hass, mock_get_device_info_exception): +async def test_user_input_unknown_error( + hass, mock_get_device_info_exception, mock_get_source_ip +): """Test when user specifies an existing device, which does not provide the musiccast API.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -175,7 +194,10 @@ async def test_user_input_unknown_error(hass, mock_get_device_info_exception): async def test_user_input_device_found( - hass, mock_get_device_info_valid, mock_valid_discovery_information + hass, + mock_get_device_info_valid, + mock_valid_discovery_information, + mock_get_source_ip, ): """Test when user specifies an existing device.""" result = await hass.config_entries.flow.async_init( @@ -198,7 +220,10 @@ async def test_user_input_device_found( async def test_user_input_device_found_no_ssdp( - hass, mock_get_device_info_valid, mock_empty_discovery_information + hass, + mock_get_device_info_valid, + mock_empty_discovery_information, + mock_get_source_ip, ): """Test when user specifies an existing device, which no discovery data are present for.""" result = await hass.config_entries.flow.async_init( @@ -220,7 +245,9 @@ async def test_user_input_device_found_no_ssdp( } -async def test_import_device_already_existing(hass, mock_get_device_info_valid): +async def test_import_device_already_existing( + hass, mock_get_device_info_valid, mock_get_source_ip +): """Test when the configurations.yaml contains an existing device.""" mock_entry = MockConfigEntry( domain=DOMAIN, @@ -239,7 +266,7 @@ async def test_import_device_already_existing(hass, mock_get_device_info_valid): assert result["reason"] == "already_configured" -async def test_import_error(hass, mock_get_device_info_exception): +async def test_import_error(hass, mock_get_device_info_exception, mock_get_source_ip): """Test when in the configuration.yaml a device is configured, which cannot be added..""" config = {"platform": "yamaha_musiccast", "host": "192.168.188.18", "port": 5006} @@ -252,7 +279,10 @@ async def test_import_error(hass, mock_get_device_info_exception): async def test_import_device_successful( - hass, mock_get_device_info_valid, mock_valid_discovery_information + hass, + mock_get_device_info_valid, + mock_valid_discovery_information, + mock_get_source_ip, ): """Test when the device was imported successfully.""" config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006} @@ -273,7 +303,7 @@ async def test_import_device_successful( # SSDP Flows -async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha): +async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha, mock_get_source_ip): """Test when an SSDP discovered device is not a musiccast device.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -289,7 +319,9 @@ async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha): assert result["reason"] == "yxc_control_url_missing" -async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): +async def test_ssdp_discovery_successful_add_device( + hass, mock_ssdp_yamaha, mock_get_source_ip +): """Test when the SSDP discovered device is a musiccast device and the user confirms it.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -319,7 +351,9 @@ async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): } -async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha): +async def test_ssdp_discovery_existing_device_update( + hass, mock_ssdp_yamaha, mock_get_source_ip +): """Test when the SSDP discovered device is a musiccast device, but it already exists with another IP.""" mock_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 4a862fa13dd..4d673dfaa94 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -4,7 +4,7 @@ from datetime import timedelta from ipaddress import IPv4Address from unittest.mock import AsyncMock, MagicMock, patch -from async_upnp_client.search import SSDPListener +from async_upnp_client.search import SsdpSearchListener from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS @@ -37,6 +37,17 @@ CAPABILITIES = { "name": "", } +ID_DECIMAL = f"{int(ID, 16):08d}" + +ZEROCONF_DATA = { + "host": IP_ADDRESS, + "port": 54321, + "hostname": f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", + "type": "_miio._udp.local.", + "name": f"yeelink-light-strip1_miio{ID_DECIMAL}._miio._udp.local.", + "properties": {"epoch": "1", "mac": "000000000000"}, +} + NAME = "name" SHORT_ID = hex(int("0x000000000015243f", 16)) UNIQUE_NAME = f"yeelight_{MODEL}_{SHORT_ID}" @@ -125,6 +136,7 @@ def _mocked_bulb(cannot_connect=False): ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) bulb.capabilities = CAPABILITIES.copy() + bulb.available = True bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False bulb.async_get_properties = AsyncMock() @@ -145,7 +157,7 @@ def _mocked_bulb(cannot_connect=False): def _patched_ssdp_listener(info, *args, **kwargs): - listener = SSDPListener(*args, **kwargs) + listener = SsdpSearchListener(*args, **kwargs) async def _async_callback(*_): if kwargs["source_ip"] == IPv4Address(FAIL_TO_BIND_IP): @@ -173,7 +185,7 @@ def _patch_discovery(no_device=False, capabilities=None): ) return patch( - "homeassistant.components.yeelight.SSDPListener", + "homeassistant.components.yeelight.SsdpSearchListener", new=_generate_fake_ssdp_listener, ) diff --git a/tests/components/yeelight/conftest.py b/tests/components/yeelight/conftest.py index f418e90e848..9a9b9d19ec2 100644 --- a/tests/components/yeelight/conftest.py +++ b/tests/components/yeelight/conftest.py @@ -1,2 +1,9 @@ """yeelight conftest.""" +import pytest + from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +def yeelight_mock_get_source_ip(mock_get_source_ip): + """Mock network util's async_get_source_ip.""" diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 6bc3ba68275..8d4b7f48543 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -33,6 +33,7 @@ from . import ( MODULE_CONFIG_FLOW, NAME, UNIQUE_FRIENDLY_NAME, + ZEROCONF_DATA, _mocked_bulb, _patch_discovery, _patch_discovery_interval, @@ -576,3 +577,67 @@ async def test_discovered_ssdp(hass): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + + +async def test_discovered_zeroconf(hass): + """Test we can setup when discovered from zeroconf.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=CAPABILITIES, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 4b3ac8e0e83..3ad99fa34ac 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -9,10 +9,9 @@ from homeassistant.components.yeelight import ( CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, - DATA_CONFIG_ENTRIES, - DATA_DEVICE, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, + STATE_CHANGE_TIME, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -56,41 +55,41 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): ) config_entry.add_to_hass(hass) - mocked_bulb = _mocked_bulb(True) - mocked_bulb.bulb_type = BulbType.WhiteTempMood - mocked_bulb.async_listen = AsyncMock(side_effect=[BulbException, None, None, None]) + mocked_fail_bulb = _mocked_bulb(cannot_connect=True) + mocked_fail_bulb.bulb_type = BulbType.WhiteTempMood + with patch( + f"{MODULE}.AsyncBulb", return_value=mocked_fail_bulb + ), _patch_discovery(): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + + # The discovery should update the ip address + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert config_entry.state is ConfigEntryState.SETUP_RETRY + mocked_bulb = _mocked_bulb() with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( f"yeelight_color_{SHORT_ID}" ) - - type(mocked_bulb).async_get_properties = AsyncMock(None) - - await hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][ - DATA_DEVICE - ].async_update() - await hass.async_block_till_done() - await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): - # The discovery should update the ip address - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) - await hass.async_block_till_done() - assert config_entry.data[CONF_HOST] == IP_ADDRESS - # Make sure we can still reload with the new ip right after we change it with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None @@ -327,13 +326,21 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant): ) config_entry.add_to_hass(hass) - mocked_bulb = _mocked_bulb(True) + mocked_bulb = _mocked_bulb(cannot_connect=True) mocked_bulb.bulb_type = BulbType.WhiteTempMood with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery( no_device=True ), _patch_discovery_timeout(), _patch_discovery_interval(): - assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + with patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()), _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -366,6 +373,26 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert config_entry.options[CONF_MODEL] == MODEL +async def test_unload_before_discovery(hass, caplog): + """Test unloading before discovery.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb(cannot_connect=True) + + with _patch_discovery(no_device=True), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): """Test the async listen error.""" config_entry = MockConfigEntry( @@ -380,7 +407,7 @@ async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): ): await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_async_listen_error_has_host_without_id(hass: HomeAssistant): @@ -412,9 +439,16 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant): f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) ): await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.data[CONF_ID] == ID - assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.data[CONF_ID] == ID + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): @@ -438,6 +472,8 @@ async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): await hass.async_block_till_done() assert len(mocked_bulb.async_get_properties.mock_calls) == 1 mocked_bulb._async_callback({KEY_CONNECTED: True}) - await hass.async_block_till_done() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=STATE_CHANGE_TIME) + ) await hass.async_block_till_done() assert len(mocked_bulb.async_get_properties.mock_calls) == 2 diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 030f6a54cea..fd6e12f2635 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1,7 +1,10 @@ """Test the Yeelight light.""" +import asyncio import logging +import socket from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +import pytest from yeelight import ( BulbException, BulbType, @@ -28,6 +31,7 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ATTR_TRANSITION, FLASH_LONG, + FLASH_SHORT, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -82,8 +86,16 @@ from homeassistant.components.yeelight.light import ( YEELIGHT_MONO_EFFECT_LIST, YEELIGHT_TEMP_ONLY_EFFECT_LIST, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.color import ( @@ -122,6 +134,7 @@ CONFIG_ENTRY_DATA = { async def test_services(hass: HomeAssistant, caplog): """Test Yeelight services.""" + assert await async_setup_component(hass, "homeassistant", {}) config_entry = MockConfigEntry( domain=DOMAIN, data={ @@ -140,13 +153,16 @@ async def test_services(hass: HomeAssistant, caplog): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + assert hass.states.get(ENTITY_NIGHTLIGHT).state == STATE_OFF + async def _async_test_service( service, data, method, payload=None, domain=DOMAIN, - failure_side_effect=BulbException, + failure_side_effect=HomeAssistantError, ): err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) @@ -174,11 +190,8 @@ async def test_services(hass: HomeAssistant, caplog): else: mocked_method = MagicMock(side_effect=failure_side_effect) setattr(mocked_bulb, method, mocked_method) - await hass.services.async_call(domain, service, data, blocking=True) - assert ( - len([x for x in caplog.records if x.levelno == logging.ERROR]) - == err_count + 1 - ) + with pytest.raises(failure_side_effect): + await hass.services.async_call(domain, service, data, blocking=True) # turn_on rgb_color brightness = 100 @@ -303,7 +316,50 @@ async def test_services(hass: HomeAssistant, caplog): mocked_bulb.async_start_flow.assert_called_once() # flash mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + # turn_on color_temp - flash short + brightness = 100 + color_temp = 200 + transition = 1 + mocked_bulb.start_music.reset_mock() + mocked_bulb.async_set_brightness.reset_mock() + mocked_bulb.async_set_color_temp.reset_mock() + mocked_bulb.async_start_flow.reset_mock() + mocked_bulb.async_stop_flow.reset_mock() + mocked_bulb.last_properties["power"] = "off" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS: brightness, + ATTR_COLOR_TEMP: color_temp, + ATTR_FLASH: FLASH_SHORT, + ATTR_EFFECT: EFFECT_STOP, + ATTR_TRANSITION: transition, + }, + blocking=True, + ) + mocked_bulb.async_turn_on.assert_called_once_with( + duration=transition * 1000, + light_type=LightType.Main, + power_mode=PowerMode.NORMAL, + ) + mocked_bulb.async_turn_on.reset_mock() + mocked_bulb.start_music.assert_called_once() + mocked_bulb.async_set_brightness.assert_called_once_with( + brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main + ) + mocked_bulb.async_set_color_temp.assert_called_once_with( + color_temperature_mired_to_kelvin(color_temp), + duration=transition * 1000, + light_type=LightType.Main, + ) + mocked_bulb.async_set_hsv.assert_not_called() + mocked_bulb.async_set_rgb.assert_not_called() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + # turn_on nightlight await _async_test_service( SERVICE_TURN_ON, @@ -318,6 +374,7 @@ async def test_services(hass: HomeAssistant, caplog): ) mocked_bulb.last_properties["power"] = "on" + assert hass.states.get(ENTITY_LIGHT).state != STATE_UNAVAILABLE # turn_off await _async_test_service( SERVICE_TURN_OFF, @@ -393,12 +450,16 @@ async def test_services(hass: HomeAssistant, caplog): ) # set_music_mode failure enable - await _async_test_service( + mocked_bulb.start_music = MagicMock(side_effect=AssertionError) + assert "Unable to turn on music mode, consider disabling it" not in caplog.text + await hass.services.async_call( + DOMAIN, SERVICE_SET_MUSIC_MODE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE_MUSIC: "true"}, - "start_music", - failure_side_effect=AssertionError, + blocking=True, ) + assert mocked_bulb.start_music.mock_calls == [call()] + assert "Unable to turn on music mode, consider disabling it" in caplog.text # set_music_mode disable await _async_test_service( @@ -417,18 +478,95 @@ async def test_services(hass: HomeAssistant, caplog): ) # test _cmd wrapper error handler mocked_bulb.last_properties["power"] = "off" - err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) - type(mocked_bulb).turn_on = MagicMock() - type(mocked_bulb).set_brightness = MagicMock(side_effect=BulbException) + mocked_bulb.available = True await hass.services.async_call( - "light", - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ENTITY_LIGHT}, blocking=True, ) - assert ( - len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + 1 + assert hass.states.get(ENTITY_LIGHT).state == STATE_OFF + + mocked_bulb.async_turn_on = AsyncMock() + mocked_bulb.async_set_brightness = AsyncMock(side_effect=BulbException) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_OFF + + mocked_bulb.async_set_brightness = AsyncMock(side_effect=asyncio.TimeoutError) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 55}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_OFF + + mocked_bulb.async_set_brightness = AsyncMock(side_effect=socket.error) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 55}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + +async def test_update_errors(hass: HomeAssistant, caplog): + """Test update errors.""" + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **CONFIG_ENTRY_DATA, + CONF_MODE_MUSIC: True, + CONF_SAVE_ON_CHANGE: True, + CONF_NIGHTLIGHT_SWITCH: True, + }, ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + assert hass.states.get(ENTITY_NIGHTLIGHT).state == STATE_OFF + + # Timeout usually means the bulb is overloaded with commands + # but will still respond eventually. + mocked_bulb.async_turn_off = AsyncMock(side_effect=asyncio.TimeoutError) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + # socket.error usually means the bulb dropped the connection + # or lost wifi, then came back online and forced the existing + # connection closed with a TCP RST + mocked_bulb.async_turn_off = AsyncMock(side_effect=socket.error) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): @@ -436,6 +574,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): mocked_bulb = _mocked_bulb() properties = {**PROPERTIES} properties.pop("active_mode") + properties.pop("nl_br") properties["color_mode"] = "3" # HSV mocked_bulb.last_properties = properties mocked_bulb.bulb_type = BulbType.Color @@ -443,7 +582,9 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} ) config_entry.add_to_hass(hass) - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # We use asyncio.create_task now to avoid @@ -487,7 +628,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): SERVICE_TURN_ON, { ATTR_ENTITY_ID: ENTITY_LIGHT, - ATTR_BRIGHTNESS_PCT: PROPERTIES["current_brightness"], + ATTR_BRIGHTNESS_PCT: PROPERTIES["bright"], }, blocking=True, ) @@ -560,9 +701,10 @@ async def test_device_types(hass: HomeAssistant, caplog): bulb_type, model, target_properties, - nightlight_properties=None, + nightlight_entity_properties=None, name=UNIQUE_FRIENDLY_NAME, entity_id=ENTITY_LIGHT, + nightlight_mode_properties=None, ): config_entry = MockConfigEntry( domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} @@ -572,6 +714,9 @@ async def test_device_types(hass: HomeAssistant, caplog): mocked_bulb.bulb_type = bulb_type model_specs = _MODEL_SPECS.get(model) type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs) + original_nightlight_brightness = mocked_bulb.last_properties["nl_br"] + + mocked_bulb.last_properties["nl_br"] = "0" await _async_setup(config_entry) state = hass.states.get(entity_id) @@ -579,41 +724,58 @@ async def test_device_types(hass: HomeAssistant, caplog): assert state.state == "on" target_properties["friendly_name"] = name target_properties["flowing"] = False - target_properties["night_light"] = True + target_properties["night_light"] = False target_properties["music_mode"] = False assert dict(state.attributes) == target_properties - await hass.config_entries.async_unload(config_entry.entry_id) await config_entry.async_remove(hass) registry = er.async_get(hass) registry.async_clear_config_entry(config_entry.entry_id) + mocked_bulb.last_properties["nl_br"] = original_nightlight_brightness - # nightlight - if nightlight_properties is None: - return - config_entry = MockConfigEntry( - domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True} - ) - config_entry.add_to_hass(hass) - await _async_setup(config_entry) + # nightlight as a setting of the main entity + if nightlight_mode_properties is not None: + mocked_bulb.last_properties["active_mode"] = True + config_entry.add_to_hass(hass) + await _async_setup(config_entry) + state = hass.states.get(entity_id) + assert state.state == "on" + nightlight_mode_properties["friendly_name"] = name + nightlight_mode_properties["flowing"] = False + nightlight_mode_properties["night_light"] = True + nightlight_mode_properties["music_mode"] = False + assert dict(state.attributes) == nightlight_mode_properties - assert hass.states.get(entity_id).state == "off" - state = hass.states.get(f"{entity_id}_nightlight") - assert state.state == "on" - nightlight_properties["friendly_name"] = f"{name} Nightlight" - nightlight_properties["icon"] = "mdi:weather-night" - nightlight_properties["flowing"] = False - nightlight_properties["night_light"] = True - nightlight_properties["music_mode"] = False - assert dict(state.attributes) == nightlight_properties + await hass.config_entries.async_unload(config_entry.entry_id) + await config_entry.async_remove(hass) + registry.async_clear_config_entry(config_entry.entry_id) + await hass.async_block_till_done() + mocked_bulb.last_properties.pop("active_mode") - await hass.config_entries.async_unload(config_entry.entry_id) - await config_entry.async_remove(hass) - registry.async_clear_config_entry(config_entry.entry_id) - await hass.async_block_till_done() + # nightlight as a separate entity + if nightlight_entity_properties is not None: + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True} + ) + config_entry.add_to_hass(hass) + await _async_setup(config_entry) + + assert hass.states.get(entity_id).state == "off" + state = hass.states.get(f"{entity_id}_nightlight") + assert state.state == "on" + nightlight_entity_properties["friendly_name"] = f"{name} Nightlight" + nightlight_entity_properties["icon"] = "mdi:weather-night" + nightlight_entity_properties["flowing"] = False + nightlight_entity_properties["night_light"] = True + nightlight_entity_properties["music_mode"] = False + assert dict(state.attributes) == nightlight_entity_properties + + await hass.config_entries.async_unload(config_entry.entry_id) + await config_entry.async_remove(hass) + registry.async_clear_config_entry(config_entry.entry_id) + await hass.async_block_till_done() bright = round(255 * int(PROPERTIES["bright"]) / 100) - current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) hue = int(PROPERTIES["hue"]) sat = int(PROPERTIES["sat"]) @@ -670,7 +832,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], @@ -678,11 +840,30 @@ async def test_device_types(hass: HomeAssistant, caplog): "rgb_color": (255, 205, 166), "xy_color": (0.421, 0.364), }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], }, + nightlight_mode_properties={ + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "hs_color": (28.401, 100.0), + "rgb_color": (255, 120, 0), + "xy_color": (0.621, 0.367), + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": nl_br, + "color_mode": "color_temp", + "supported_color_modes": ["color_temp", "hs", "rgb"], + "color_temp": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + }, ) # Color - color mode HS @@ -700,14 +881,14 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "hs_color": hs_color, "rgb_color": color_hs_to_RGB(*hs_color), "xy_color": color_hs_to_xy(*hs_color), "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -729,14 +910,14 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "hs_color": color_RGB_to_hs(*rgb_color), "rgb_color": rgb_color, "xy_color": color_RGB_to_xy(*rgb_color), "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -759,11 +940,11 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -786,11 +967,11 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - { + nightlight_entity_properties={ "supported_features": 0, "color_mode": "onoff", "supported_color_modes": ["onoff"], @@ -837,7 +1018,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], @@ -845,13 +1026,31 @@ async def test_device_types(hass: HomeAssistant, caplog): "rgb_color": (255, 205, 166), "xy_color": (0.421, 0.364), }, - { - "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, - "supported_features": SUPPORT_YEELIGHT, + nightlight_entity_properties={ + "supported_features": 0, "brightness": nl_br, "color_mode": "brightness", "supported_color_modes": ["brightness"], }, + nightlight_mode_properties={ + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": nl_br, + "color_temp": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "color_mode": "color_temp", + "supported_color_modes": ["color_temp"], + "hs_color": (28.391, 65.659), + "rgb_color": (255, 166, 87), + "xy_color": (0.526, 0.387), + }, ) # WhiteTempMood @@ -873,7 +1072,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), - "brightness": current_brightness, + "brightness": bright, "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], @@ -881,13 +1080,34 @@ async def test_device_types(hass: HomeAssistant, caplog): "rgb_color": (255, 205, 166), "xy_color": (0.421, 0.364), }, - { - "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, - "supported_features": SUPPORT_YEELIGHT, + nightlight_entity_properties={ + "supported_features": 0, "brightness": nl_br, "color_mode": "brightness", "supported_color_modes": ["brightness"], }, + nightlight_mode_properties={ + "friendly_name": NAME, + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "flowing": False, + "night_light": True, + "supported_features": SUPPORT_YEELIGHT, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": nl_br, + "color_temp": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "color_mode": "color_temp", + "supported_color_modes": ["color_temp"], + "hs_color": (28.391, 65.659), + "rgb_color": (255, 166, 87), + "xy_color": (0.526, 0.387), + }, ) # Background light - color mode CT mocked_bulb.last_properties["bg_lmode"] = "2" # CT @@ -1125,62 +1345,6 @@ async def test_effects(hass: HomeAssistant): await _async_test_effect("not_existed", called=False) -async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): - """Ensure we call async_get_properties if the turn on/off fails to update the state.""" - mocked_bulb = _mocked_bulb() - properties = {**PROPERTIES} - properties.pop("active_mode") - properties["color_mode"] = "3" # HSV - mocked_bulb.last_properties = properties - mocked_bulb.bulb_type = BulbType.Color - config_entry = MockConfigEntry( - domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} - ) - config_entry.add_to_hass(hass) - with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # We use asyncio.create_task now to avoid - # blocking starting so we need to block again - await hass.async_block_till_done() - - mocked_bulb.last_properties["power"] = "off" - await hass.services.async_call( - "light", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: ENTITY_LIGHT, - }, - blocking=True, - ) - assert len(mocked_bulb.async_turn_on.mock_calls) == 1 - assert len(mocked_bulb.async_get_properties.mock_calls) == 2 - - mocked_bulb.last_properties["power"] = "on" - await hass.services.async_call( - "light", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: ENTITY_LIGHT, - }, - blocking=True, - ) - assert len(mocked_bulb.async_turn_off.mock_calls) == 1 - assert len(mocked_bulb.async_get_properties.mock_calls) == 3 - - # But if the state is correct no calls - await hass.services.async_call( - "light", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: ENTITY_LIGHT, - }, - blocking=True, - ) - assert len(mocked_bulb.async_turn_on.mock_calls) == 1 - assert len(mocked_bulb.async_get_properties.mock_calls) == 3 - - async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): """Test that main light on ambilights with the nightlight disabled shows the correct brightness.""" mocked_bulb = _mocked_bulb() @@ -1189,7 +1353,6 @@ async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): capabilities["model"] = "ceiling10" properties["color_mode"] = "3" # HSV properties["bg_power"] = "off" - properties["current_brightness"] = 0 properties["bg_lmode"] = "2" # CT mocked_bulb.last_properties = properties mocked_bulb.bulb_type = BulbType.WhiteTempMood diff --git a/tests/components/zeroconf/conftest.py b/tests/components/zeroconf/conftest.py index 5ccd617f84f..cbe2ec8dc26 100644 --- a/tests/components/zeroconf/conftest.py +++ b/tests/components/zeroconf/conftest.py @@ -4,8 +4,14 @@ from unittest.mock import AsyncMock, patch import pytest +@pytest.fixture(autouse=True) +def zc_mock_get_source_ip(mock_get_source_ip): + """Enable the mock_get_source_ip fixture for all zeroconf tests.""" + return mock_get_source_ip + + @pytest.fixture -def mock_async_zeroconf(): +def mock_async_zeroconf(mock_zeroconf): """Mock AsyncZeroconf.""" with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: zc = mock_aiozc.return_value diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index d13bbc97547..0d3c5fc7792 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -679,9 +679,6 @@ async def test_removed_ignored(hass, mock_async_zeroconf): await hass.async_block_till_done() assert len(mock_service_info.mock_calls) == 2 - import pprint - - pprint.pprint(mock_service_info.mock_calls[0][1]) assert mock_service_info.mock_calls[0][1][0] == "_service.added.local." assert mock_service_info.mock_calls[1][1][0] == "_service.updated.local." @@ -782,11 +779,13 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zeroconf): - """Test without default interface config and the route returns nothing.""" - with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( - hass.config_entries.flow, "async_init" - ), patch.object( +async def test_async_detect_interfaces_setting_empty_route_linux( + hass, mock_async_zeroconf +): + """Test without default interface config and the route returns nothing on linux.""" + with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", @@ -810,6 +809,33 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero ) +async def test_async_detect_interfaces_setting_empty_route_freebsd( + hass, mock_async_zeroconf +): + """Test without default interface config and the route returns nothing on freebsd.""" + with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ): + 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=[ + "192.168.1.5", + "172.16.1.5", + ], + ip_version=IPVersion.V4Only, + ) + + async def test_get_announced_addresses(hass, mock_async_zeroconf): """Test addresses for mDNS announcement.""" expected = { @@ -851,11 +877,13 @@ _ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ ] -async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zeroconf): - """Test interfaces are explicitly set when IPv6 is present.""" - with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( - hass.config_entries.flow, "async_init" - ), patch.object( +async def test_async_detect_interfaces_explicitly_set_ipv6_linux( + hass, mock_async_zeroconf +): + """Test interfaces are explicitly set when IPv6 is present on linux.""" + with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", @@ -874,6 +902,31 @@ async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zero ) +async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( + hass, mock_async_zeroconf +): + """Test interfaces are explicitly set when IPv6 is present on freebsd.""" + with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ): + 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=InterfaceChoice.Default, + ip_version=IPVersion.V4Only, + ) + + async def test_no_name(hass, mock_async_zeroconf): """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 5180e9dbc07..b302869d9e4 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -1,74 +1,15 @@ """Common test objects.""" import asyncio -import time +import math from unittest.mock import AsyncMock, Mock -import zigpy.device as zigpy_dev -import zigpy.endpoint as zigpy_ep -import zigpy.profiles.zha -import zigpy.types import zigpy.zcl -import zigpy.zcl.clusters.general import zigpy.zcl.foundation as zcl_f -import zigpy.zdo.types import homeassistant.components.zha.core.const as zha_const from homeassistant.util import slugify -class FakeEndpoint: - """Fake endpoint for moking zigpy.""" - - def __init__(self, manufacturer, model, epid=1): - """Init fake endpoint.""" - self.device = None - self.endpoint_id = epid - self.in_clusters = {} - self.out_clusters = {} - self._cluster_attr = {} - self.member_of = {} - self.status = zigpy_ep.Status.ZDO_INIT - self.manufacturer = manufacturer - self.model = model - self.profile_id = zigpy.profiles.zha.PROFILE_ID - self.device_type = None - self.request = AsyncMock(return_value=[0]) - - def add_input_cluster(self, cluster_id, _patch_cluster=True): - """Add an input cluster.""" - cluster = zigpy.zcl.Cluster.from_id(self, cluster_id, is_server=True) - if _patch_cluster: - patch_cluster(cluster) - self.in_clusters[cluster_id] = cluster - ep_attribute = cluster.ep_attribute - if ep_attribute: - setattr(self, ep_attribute, cluster) - - def add_output_cluster(self, cluster_id, _patch_cluster=True): - """Add an output cluster.""" - cluster = zigpy.zcl.Cluster.from_id(self, cluster_id, is_server=False) - if _patch_cluster: - patch_cluster(cluster) - self.out_clusters[cluster_id] = cluster - - reply = AsyncMock(return_value=[0]) - request = AsyncMock(return_value=[0]) - - @property - def __class__(self): - """Fake being Zigpy endpoint.""" - return zigpy_ep.Endpoint - - @property - def unique_id(self): - """Return the unique id for the endpoint.""" - return self.device.ieee, self.endpoint_id - - -FakeEndpoint.add_to_group = zigpy_ep.Endpoint.add_to_group -FakeEndpoint.remove_from_group = zigpy_ep.Endpoint.remove_from_group - - def patch_cluster(cluster): """Patch a cluster for testing.""" cluster.PLUGGED_ATTR_READS = {} @@ -99,45 +40,32 @@ def patch_cluster(cluster): [zcl_f.ConfigureReportingResponseRecord(zcl_f.Status.SUCCESS, 0x00, 0xAABB)] ] ) + cluster.configure_reporting_multiple = AsyncMock( + return_value=zcl_f.ConfigureReportingResponse.deserialize(b"\x00")[0] + ) cluster.deserialize = Mock() cluster.handle_cluster_request = Mock() cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes) cluster.read_attributes_raw = AsyncMock(side_effect=_read_attribute_raw) cluster.unbind = AsyncMock(return_value=[0]) - cluster.write_attributes = AsyncMock( + cluster.write_attributes = AsyncMock(wraps=cluster.write_attributes) + cluster._write_attributes = AsyncMock( return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]] ) if cluster.cluster_id == 4: cluster.add = AsyncMock(return_value=[0]) -class FakeDevice: - """Fake device for mocking zigpy.""" - - def __init__(self, app, ieee, manufacturer, model, node_desc=None, nwk=0xB79C): - """Init fake device.""" - self._application = app - self.application = app - self.ieee = zigpy.types.EUI64.convert(ieee) - self.nwk = nwk - self.zdo = Mock() - self.endpoints = {0: self.zdo} - self.lqi = 255 - self.rssi = 8 - self.last_seen = time.time() - self.status = zigpy_dev.Status.ENDPOINTS_INIT - self.initializing = False - self.skip_configuration = False - self.manufacturer = manufacturer - self.model = model - self.remove_from_group = AsyncMock() - if node_desc is None: - node_desc = b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00" - self.node_desc = zigpy.zdo.types.NodeDescriptor.deserialize(node_desc)[0] - self.neighbors = [] - - -FakeDevice.add_to_group = zigpy_dev.Device.add_to_group +def update_attribute_cache(cluster): + """Update attribute cache based on plugged attributes.""" + if cluster.PLUGGED_ATTR_READS: + attrs = [ + make_attribute(cluster.attridx.get(attr, attr), value) + for attr, value in cluster.PLUGGED_ATTR_READS.items() + ] + hdr = make_zcl_header(zcl_f.Command.Report_Attributes) + hdr.frame_control.disable_default_response = True + cluster.handle_message(hdr, [attrs]) def get_zha_gateway(hass): @@ -162,13 +90,16 @@ def send_attribute_report(hass, cluster, attrid, value): return send_attributes_report(hass, cluster, {attrid: value}) -async def send_attributes_report(hass, cluster: int, attributes: dict): +async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: dict): """Cause the sensor to receive an attribute report from the network. This is to simulate the normal device communication that happens when a device is paired to the zigbee network. """ - attrs = [make_attribute(attrid, value) for attrid, value in attributes.items()] + attrs = [ + make_attribute(cluster.attridx.get(attr, attr), value) + for attr, value in attributes.items() + ] hdr = make_zcl_header(zcl_f.Command.Report_Attributes) hdr.frame_control.disable_default_response = True cluster.handle_message(hdr, [attrs]) @@ -178,6 +109,18 @@ async def send_attributes_report(hass, cluster: int, attributes: dict): async def find_entity_id(domain, zha_device, hass): """Find the entity id under the testing. + This is used to get the entity id in order to get the state from the state + machine so that we can test state changes. + """ + entities = await find_entity_ids(domain, zha_device, hass) + if not entities: + return None + return entities[0] + + +async def find_entity_ids(domain, zha_device, hass): + """Find the entity ids under the testing. + This is used to get the entity id in order to get the state from the state machine so that we can test state changes. """ @@ -187,10 +130,11 @@ async def find_entity_id(domain, zha_device, hass): enitiy_ids = hass.states.async_entity_ids(domain) await hass.async_block_till_done() + res = [] for entity_id in enitiy_ids: if entity_id.startswith(head): - return entity_id - return None + res.append(entity_id) + return res def async_find_group_entity_id(hass, domain, group): @@ -227,6 +171,7 @@ def reset_clusters(clusters): for cluster in clusters: cluster.bind.reset_mock() cluster.configure_reporting.reset_mock() + cluster.configure_reporting_multiple.reset_mock() cluster.write_attributes.reset_mock() @@ -240,8 +185,21 @@ async def async_test_rejoin(hass, zigpy_device, clusters, report_counts, ep_id=1 for cluster, reports in zip(clusters, report_counts): assert cluster.bind.call_count == 1 assert cluster.bind.await_count == 1 - assert cluster.configure_reporting.call_count == reports - assert cluster.configure_reporting.await_count == reports + if reports: + assert cluster.configure_reporting.call_count == 0 + assert cluster.configure_reporting.await_count == 0 + assert cluster.configure_reporting_multiple.call_count == math.ceil( + reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ + ) + assert cluster.configure_reporting_multiple.await_count == math.ceil( + reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ + ) + else: + # no reports at all + assert cluster.configure_reporting.call_count == reports + assert cluster.configure_reporting.await_count == reports + assert cluster.configure_reporting_multiple.call_count == reports + assert cluster.configure_reporting_multiple.await_count == reports async def async_wait_for_updates(hass): diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index df90256b3a8..fd138567367 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,22 +1,27 @@ """Test configuration for the ZHA component.""" +import itertools +import time from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest import zigpy from zigpy.application import ControllerApplication import zigpy.config +from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +import zigpy.device import zigpy.group +import zigpy.profiles import zigpy.types +import zigpy.zdo.types as zdo_t from homeassistant.components.zha import DOMAIN import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device from homeassistant.setup import async_setup_component -from .common import FakeDevice, FakeEndpoint, get_zha_gateway - from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 +from tests.components.zha import common FIXTURE_GRP_ID = 0x1001 FIXTURE_GRP_NAME = "fixture group" @@ -112,25 +117,39 @@ def zigpy_device_mock(zigpy_app_controller): node_descriptor=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", nwk=0xB79C, patch_cluster=True, + quirk=None, ): """Make a fake device using the specified cluster classes.""" - device = FakeDevice( - zigpy_app_controller, ieee, manufacturer, model, node_descriptor, nwk=nwk + device = zigpy.device.Device( + zigpy_app_controller, zigpy.types.EUI64.convert(ieee), nwk ) + device.manufacturer = manufacturer + device.model = model + device.node_desc = zdo_t.NodeDescriptor.deserialize(node_descriptor)[0] + device.last_seen = time.time() + for epid, ep in endpoints.items(): - endpoint = FakeEndpoint(manufacturer, model, epid) - endpoint.device = device - device.endpoints[epid] = endpoint - endpoint.device_type = ep["device_type"] - profile_id = ep.get("profile_id") - if profile_id: - endpoint.profile_id = profile_id + endpoint = device.add_endpoint(epid) + endpoint.device_type = ep[SIG_EP_TYPE] + endpoint.profile_id = ep.get(SIG_EP_PROFILE) + endpoint.request = AsyncMock(return_value=[0]) - for cluster_id in ep.get("in_clusters", []): - endpoint.add_input_cluster(cluster_id, _patch_cluster=patch_cluster) + for cluster_id in ep.get(SIG_EP_INPUT, []): + endpoint.add_input_cluster(cluster_id) - for cluster_id in ep.get("out_clusters", []): - endpoint.add_output_cluster(cluster_id, _patch_cluster=patch_cluster) + for cluster_id in ep.get(SIG_EP_OUTPUT, []): + endpoint.add_output_cluster(cluster_id) + + if quirk: + device = quirk(zigpy_app_controller, device.ieee, device.nwk, device) + + if patch_cluster: + for endpoint in (ep for epid, ep in device.endpoints.items() if epid): + endpoint.request = AsyncMock(return_value=[0]) + for cluster in itertools.chain( + endpoint.in_clusters.values(), endpoint.out_clusters.values() + ): + common.patch_cluster(cluster) return device @@ -143,7 +162,7 @@ def zha_device_joined(hass, setup_zha): async def _zha_device(zigpy_dev): await setup_zha() - zha_gateway = get_zha_gateway(hass) + zha_gateway = common.get_zha_gateway(hass) await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() return zha_gateway.get_device(zigpy_dev.ieee) diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index c3428a044a4..39063225e50 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ) from .common import async_enable_traffic, find_entity_id +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @pytest.fixture @@ -25,9 +26,10 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [security.IasAce.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, } } return zigpy_device_mock( diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 288f886a865..4e97f35bf1d 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -40,7 +40,14 @@ from homeassistant.components.zha.core.const import ( from homeassistant.const import ATTR_NAME from homeassistant.core import Context -from .conftest import FIXTURE_GRP_ID, FIXTURE_GRP_NAME +from .conftest import ( + FIXTURE_GRP_ID, + FIXTURE_GRP_NAME, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, +) IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -53,9 +60,10 @@ async def device_switch(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.OnOff.cluster_id, general.Basic.cluster_id], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } }, ieee=IEEE_SWITCH_DEVICE, @@ -72,13 +80,14 @@ async def device_groupable(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.Basic.cluster_id, general.Groups.cluster_id, ], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE, diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 7a2217521ad..1ab638d0b26 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -13,21 +13,24 @@ from .common import ( find_entity_id, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE DEVICE_IAS = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.IAS_ZONE, - "in_clusters": [security.IasZone.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_INPUT: [security.IasZone.cluster_id], + SIG_EP_OUTPUT: [], } } DEVICE_OCCUPANCY = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, - "in_clusters": [measurement.OccupancySensing.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, + SIG_EP_INPUT: [measurement.OccupancySensing.cluster_id], + SIG_EP_OUTPUT: [], } } diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index bd7fd3f9207..c1e60db31dd 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -1,5 +1,6 @@ """Test ZHA Core channels.""" import asyncio +import math from unittest import mock from unittest.mock import AsyncMock, patch @@ -14,6 +15,7 @@ import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.registries as registries from .common import get_zha_gateway, make_zcl_header +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events @@ -42,7 +44,7 @@ def zigpy_coordinator_device(zigpy_device_mock): """Coordinator device fixture.""" coordinator = zigpy_device_mock( - {1: {"in_clusters": [0x1000], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [0x1000], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -68,7 +70,7 @@ def poll_control_ch(channel_pool, zigpy_device_mock): """Poll control channel fixture.""" cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id zigpy_dev = zigpy_device_mock( - {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -84,7 +86,7 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): """Poll control device fixture.""" cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id zigpy_dev = zigpy_device_mock( - {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -123,6 +125,23 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): (0x0020, 1, {}), (0x0021, 0, {}), (0x0101, 1, {"lock_state"}), + ( + 0x0201, + 1, + { + "local_temp", + "occupied_cooling_setpoint", + "occupied_heating_setpoint", + "unoccupied_cooling_setpoint", + "unoccupied_heating_setpoint", + "running_mode", + "running_state", + "system_mode", + "occupancy", + "pi_cooling_demand", + "pi_heating_demand", + }, + ), (0x0202, 1, {"fan_mode"}), (0x0300, 1, {"current_x", "current_y", "color_temperature"}), (0x0400, 1, {"measured_value"}), @@ -141,7 +160,7 @@ async def test_in_channel_config( ): """Test ZHA core channel configuration for input clusters.""" zigpy_dev = zigpy_device_mock( - {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -156,8 +175,14 @@ async def test_in_channel_config( await channel.async_configure() assert cluster.bind.call_count == bind_count - assert cluster.configure_reporting.call_count == len(attrs) - reported_attrs = {attr[0][0] for attr in cluster.configure_reporting.call_args_list} + assert cluster.configure_reporting.call_count == 0 + assert cluster.configure_reporting_multiple.call_count == math.ceil(len(attrs) / 3) + reported_attrs = { + a + for a in attrs + for attr in cluster.configure_reporting_multiple.call_args_list + for attrs in attr[0][0] + } assert set(attrs) == reported_attrs @@ -197,7 +222,7 @@ async def test_out_channel_config( ): """Test ZHA core channel configuration for output clusters.""" zigpy_dev = zigpy_device_mock( - {1: {"out_clusters": [cluster_id], "in_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_OUTPUT: [cluster_id], SIG_EP_INPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", @@ -304,14 +329,14 @@ def test_ep_channels_all_channels(m1, zha_device_mock): zha_device = zha_device_mock( { 1: { - "in_clusters": [0, 1, 6, 8], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [0, 1, 6, 8], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, }, 2: { - "in_clusters": [0, 1, 6, 8, 768], - "out_clusters": [], - "device_type": 0x0000, + SIG_EP_INPUT: [0, 1, 6, 8, 768], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, }, } ) @@ -355,11 +380,11 @@ def test_channel_power_config(m1, zha_device_mock): in_clusters = [0, 1, 6, 8] zha_device = zha_device_mock( { - 1: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}, + 1: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, 2: { - "in_clusters": [*in_clusters, 768], - "out_clusters": [], - "device_type": 0x0000, + SIG_EP_INPUT: [*in_clusters, 768], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, }, } ) @@ -378,8 +403,8 @@ def test_channel_power_config(m1, zha_device_mock): zha_device = zha_device_mock( { - 1: {"in_clusters": [], "out_clusters": [], "device_type": 0x0000}, - 2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}, + 1: {SIG_EP_INPUT: [], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, + 2: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, } ) channels = zha_channels.Channels.new(zha_device) @@ -388,7 +413,7 @@ def test_channel_power_config(m1, zha_device_mock): assert "2:0x0001" in pools[2].all_channels zha_device = zha_device_mock( - {2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}} + {2: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}} ) channels = zha_channels.Channels.new(zha_device) pools = {pool.id: pool for pool in channels.pools} @@ -532,7 +557,7 @@ def zigpy_zll_device(zigpy_device_mock): """ZLL device fixture.""" return zigpy_device_mock( - {1: {"in_clusters": [0x1000], "out_clusters": [], "device_type": 0x1234}}, + {1: {SIG_EP_INPUT: [0x1000], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", "test manufacturer", "test model", diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index ea2d6dfb7e3..e452d90d60f 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -3,6 +3,9 @@ from unittest.mock import patch import pytest +import zhaquirks.sinope.thermostat +import zhaquirks.tuya.valve +import zigpy.profiles import zigpy.zcl.clusters from zigpy.zcl.clusters.hvac import Thermostat import zigpy.zcl.foundation as zcl_f @@ -51,74 +54,85 @@ from homeassistant.components.zha.core.const import PRESET_COMPLEX, PRESET_SCHED from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN from .common import async_enable_traffic, find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE CLIMATE = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } CLIMATE_FAN = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Fan.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } CLIMATE_SINOPE = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, 65281, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], - "profile_id": 260, + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], + }, + 196: { + SIG_EP_PROFILE: 0xC25D, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [zigpy.zcl.clusters.general.PowerConfiguration.cluster_id], + SIG_EP_OUTPUT: [], }, } CLIMATE_ZEN = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Fan.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } CLIMATE_MOES = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ zigpy.zcl.clusters.general.Basic.cluster_id, zigpy.zcl.clusters.general.Identify.cluster_id, zigpy.zcl.clusters.hvac.Thermostat.cluster_id, zigpy.zcl.clusters.hvac.UserInterface.cluster_id, 61148, ], - "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], } } MANUF_SINOPE = "Sinope Technologies" @@ -153,13 +167,13 @@ ZCL_ATTR_PLUG = { def device_climate_mock(hass, zigpy_device_mock, zha_device_joined): """Test regular thermostat device.""" - async def _dev(clusters, plug=None, manuf=None): + async def _dev(clusters, plug=None, manuf=None, quirk=None): if plug is None: plugged_attrs = ZCL_ATTR_PLUG else: plugged_attrs = {**ZCL_ATTR_PLUG, **plug} - zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf) + zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf, quirk=quirk) zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = plugged_attrs zha_device = await zha_device_joined(zigpy_device) await async_enable_traffic(hass, [zha_device]) @@ -192,7 +206,11 @@ async def device_climate_fan(device_climate_mock): async def device_climate_sinope(device_climate_mock): """Sinope thermostat.""" - return await device_climate_mock(CLIMATE_SINOPE, manuf=MANUF_SINOPE) + return await device_climate_mock( + CLIMATE_SINOPE, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) @pytest.fixture @@ -206,7 +224,9 @@ async def device_climate_zen(device_climate_mock): async def device_climate_moes(device_climate_mock): """MOES thermostat.""" - return await device_climate_mock(CLIMATE_MOES, manuf=MANUF_MOES) + return await device_climate_mock( + CLIMATE_MOES, manuf=MANUF_MOES, quirk=zhaquirks.tuya.valve.MoesHY368_Type1 + ) def test_sequence_mappings(): @@ -450,22 +470,18 @@ async def test_target_temperature( ): """Test target temperature property.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2200, - "system_mode": sys_mode, - "unoccupied_heating_setpoint": 1600, - "unoccupied_cooling_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2200, + "system_mode": sys_mode, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) if preset: await hass.services.async_call( @@ -492,20 +508,16 @@ async def test_target_temperature_high( ): """Test target temperature high property.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 1700, - "system_mode": Thermostat.SystemMode.Auto, - "unoccupied_cooling_setpoint": unoccupied, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 1700, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_cooling_setpoint": unoccupied, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) if preset: await hass.services.async_call( @@ -532,20 +544,16 @@ async def test_target_temperature_low( ): """Test target temperature low property.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_heating_setpoint": 2100, - "system_mode": Thermostat.SystemMode.Auto, - "unoccupied_heating_setpoint": unoccupied, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_heating_setpoint": 2100, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": unoccupied, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) if preset: await hass.services.async_call( @@ -742,22 +750,18 @@ async def test_set_temperature_hvac_mode(hass, device_climate): async def test_set_temperature_heat_cool(hass, device_climate_mock): """Test setting temperature service call in heating/cooling HVAC mode.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2000, - "system_mode": Thermostat.SystemMode.Auto, - "unoccupied_heating_setpoint": 1600, - "unoccupied_cooling_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat @@ -832,22 +836,18 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock): async def test_set_temperature_heat(hass, device_climate_mock): """Test setting temperature service call in heating HVAC mode.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2000, - "system_mode": Thermostat.SystemMode.Heat, - "unoccupied_heating_setpoint": 1600, - "unoccupied_cooling_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Heat, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat @@ -915,22 +915,18 @@ async def test_set_temperature_heat(hass, device_climate_mock): async def test_set_temperature_cool(hass, device_climate_mock): """Test setting temperature service call in cooling HVAC mode.""" - with patch.object( - zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, - "ep_attribute", - "sinope_manufacturer_specific", - ): - device_climate = await device_climate_mock( - CLIMATE_SINOPE, - { - "occupied_cooling_setpoint": 2500, - "occupied_heating_setpoint": 2000, - "system_mode": Thermostat.SystemMode.Cool, - "unoccupied_cooling_setpoint": 1600, - "unoccupied_heating_setpoint": 2700, - }, - manuf=MANUF_SINOPE, - ) + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Cool, + "unoccupied_cooling_setpoint": 1600, + "unoccupied_heating_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) entity_id = await find_entity_id(DOMAIN, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index f551e9dac1f..5aef30c854d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -506,7 +506,7 @@ async def test_user_flow_existing_config_entry(hass): assert result["type"] == "abort" -@patch("zigpy_cc.zigbee.application.ControllerApplication.probe", return_value=False) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) @patch( "zigpy_deconz.zigbee.application.ControllerApplication.probe", return_value=False ) @@ -514,7 +514,7 @@ async def test_user_flow_existing_config_entry(hass): "zigpy_zigate.zigbee.application.ControllerApplication.probe", return_value=False ) @patch("zigpy_xbee.zigbee.application.ControllerApplication.probe", return_value=False) -async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, hass): +async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, znp_probe, hass): """Test detect radios.""" app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE @@ -527,6 +527,7 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha with p1 as probe_mock: res = await config_flow.detect_radios("/dev/null") assert probe_mock.await_count == 1 + assert znp_probe.await_count == 1 # ZNP appears earlier in the radio list assert res[CONF_RADIO_TYPE] == "ezsp" assert zigpy.config.CONF_DEVICE in res assert ( @@ -538,10 +539,10 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha assert xbee_probe.await_count == 1 assert zigate_probe.await_count == 1 assert deconz_probe.await_count == 1 - assert cc_probe.await_count == 1 + assert znp_probe.await_count == 2 -@patch("zigpy_cc.zigbee.application.ControllerApplication.probe", return_value=False) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) @patch( "zigpy_deconz.zigbee.application.ControllerApplication.probe", return_value=False ) @@ -549,7 +550,7 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha "zigpy_zigate.zigbee.application.ControllerApplication.probe", return_value=False ) @patch("zigpy_xbee.zigbee.application.ControllerApplication.probe", return_value=False) -async def test_probe_new_ezsp(xbee_probe, zigate_probe, deconz_probe, cc_probe, hass): +async def test_probe_new_ezsp(xbee_probe, zigate_probe, deconz_probe, znp_probe, hass): """Test detect radios.""" app_ctrl_cls = MagicMock() app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE @@ -619,3 +620,38 @@ async def test_user_port_config(probe_mock, hass): ) assert result["data"][CONF_RADIO_TYPE] == "ezsp" assert probe_mock.await_count == 1 + + +@pytest.mark.parametrize( + "old_type,new_type", + [ + ("ezsp", "ezsp"), + ("ti_cc", "znp"), # only one that should change + ("znp", "znp"), + ("deconz", "deconz"), + ], +) +async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): + """Test zigpy-cc to zigpy-znp config migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=old_type + new_type, + data={ + CONF_RADIO_TYPE: old_type, + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + }, + }, + ) + + config_entry.version = 2 + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.zha.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 2 + assert config_entry.data[CONF_RADIO_TYPE] == new_type diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index c926618813c..e002e2c26f0 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -32,6 +32,7 @@ from .common import ( make_zcl_header, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_capture_events, mock_coro, mock_restore_cache @@ -42,9 +43,10 @@ def zigpy_cover_device(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.IAS_ZONE, - "in_clusters": [closures.WindowCovering.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_INPUT: [closures.WindowCovering.cluster_id], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock(endpoints) @@ -56,9 +58,10 @@ def zigpy_cover_remote(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.WINDOW_COVERING_CONTROLLER, - "in_clusters": [], - "out_clusters": [closures.WindowCovering.cluster_id], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_CONTROLLER, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [closures.WindowCovering.cluster_id], } } return zigpy_device_mock(endpoints) @@ -70,13 +73,14 @@ def zigpy_shade_device(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.SHADE, - "in_clusters": [ + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SHADE, + SIG_EP_INPUT: [ closures.Shade.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, ], - "out_clusters": [], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock(endpoints) @@ -88,9 +92,10 @@ def zigpy_keen_vent(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT, - "in_clusters": [general.LevelControl.cluster_id, general.OnOff.cluster_id], - "out_clusters": [], + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT, + SIG_EP_INPUT: [general.LevelControl.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock( diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index 0f696f21572..76877e71ffc 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -17,6 +17,7 @@ import homeassistant.helpers.device_registry as dr import homeassistant.util.dt as dt_util from .common import async_enable_traffic, make_zcl_header +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_fire_time_changed @@ -32,9 +33,9 @@ def zigpy_device(zigpy_device_mock): endpoints = { 3: { - "in_clusters": in_clusters, - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } return zigpy_device_mock(endpoints) @@ -53,9 +54,9 @@ def zigpy_device_mains(zigpy_device_mock): endpoints = { 3: { - "in_clusters": in_clusters, - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } return zigpy_device_mock( @@ -83,9 +84,9 @@ async def ota_zha_device(zha_device_restored, zigpy_device_mock): zigpy_dev = zigpy_device_mock( { 1: { - "in_clusters": [general.Basic.cluster_id], - "out_clusters": [general.Ota.cluster_id], - "device_type": 0x1234, + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: 0x1234, } }, "00:11:22:33:44:55:66:77", diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 49fa11de26c..b67f54a0a16 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -12,6 +12,8 @@ from homeassistant.components.zha import DOMAIN from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + from tests.common import async_get_device_automations, async_mock_service, mock_coro from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 @@ -28,9 +30,9 @@ async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [c.cluster_id for c in clusters], - "out_clusters": [general.OnOff.cluster_id], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [c.cluster_id for c in clusters], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } }, ) diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 0cc2b6f25c1..60dd136b9fd 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time import pytest +import zigpy.profiles.zha import zigpy.zcl.clusters.general as general from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER @@ -18,6 +19,7 @@ from .common import ( find_entity_id, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_fire_time_changed @@ -27,15 +29,16 @@ def zigpy_device_dt(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.Basic.cluster_id, general.PowerConfiguration.cluster_id, general.Identify.cluster_id, general.PollControl.cluster_id, general.BinaryInput.cluster_id, ], - "out_clusters": [general.Identify.cluster_id, general.Ota.cluster_id], - "device_type": SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE, + SIG_EP_OUTPUT: [general.Identify.cluster_id, general.Ota.cluster_id], + SIG_EP_TYPE: SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } } return zigpy_device_mock(endpoints) diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 841d6b43400..fbfc2144a6a 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -12,6 +12,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .common import async_enable_traffic +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import ( async_fire_time_changed, @@ -57,9 +58,10 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.Basic.cluster_id], - "out_clusters": [general.OnOff.cluster_id], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } } ) diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 9dc71d4aa25..d765ede0e5f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -5,6 +5,7 @@ from unittest import mock from unittest.mock import AsyncMock, patch import pytest +from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC import zigpy.profiles.zha import zigpy.quirks import zigpy.types @@ -29,9 +30,18 @@ import homeassistant.components.zha.switch import homeassistant.helpers.entity_registry from .common import get_zha_gateway -from .zha_devices_list import DEVICES +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from .zha_devices_list import ( + DEV_SIG_CHANNELS, + DEV_SIG_ENT_MAP, + DEV_SIG_ENT_MAP_CLASS, + DEV_SIG_ENT_MAP_ID, + DEV_SIG_EVT_CHANNELS, + DEVICES, +) NO_TAIL_ID = re.compile("_\\d$") +UNIQUE_ID_HD = re.compile(r"^(([\da-fA-F]{2}:){7}[\da-fA-F]{2}-\d{1,3})", re.X) @pytest.fixture @@ -72,11 +82,11 @@ async def test_devices( ) zigpy_device = zigpy_device_mock( - device["endpoints"], + device[SIG_ENDPOINTS], "00:11:22:33:44:55:66:77", - device["manufacturer"], - device["model"], - node_descriptor=device["node_descriptor"], + device[SIG_MANUFACTURER], + device[SIG_MODEL], + node_descriptor=device[SIG_NODE_DESC], patch_cluster=False, ) @@ -93,12 +103,6 @@ async def test_devices( finally: zha_channels.ChannelPool.async_new_entity = orig_new_entity - entity_ids = hass_disable_services.states.async_entity_ids() - await hass_disable_services.async_block_till_done() - zha_entity_ids = { - ent for ent in entity_ids if ent.split(".")[0] in zha_const.PLATFORMS - } - if cluster_identify: called = int(zha_device_joined_restored.name == "zha_device_joined") assert cluster_identify.request.call_count == called @@ -119,24 +123,49 @@ async def test_devices( event_channels = { ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values() } + assert event_channels == set(device[DEV_SIG_EVT_CHANNELS]) - entity_map = device["entity_map"] - assert zha_entity_ids == { - e["entity_id"] for e in entity_map.values() if not e.get("default_match", False) - } - assert event_channels == set(device["event_channels"]) - + # build a dict of entity_class -> (component, unique_id, channels) tuple + ha_ent_info = {} for call in _dispatch.call_args_list: _, component, entity_cls, unique_id, channels = call[0] - key = (component, unique_id) - entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) - assert key in entity_map - assert entity_id is not None - no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"]) - assert entity_id.startswith(no_tail_id) - assert {ch.name for ch in channels} == set(entity_map[key]["channels"]) - assert entity_cls.__name__ == entity_map[key]["entity_class"] + for comp_id, ent_info in device[DEV_SIG_ENT_MAP].items(): + component, unique_id = comp_id + no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID]) + ha_entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + assert ha_entity_id is not None + assert ha_entity_id.startswith(no_tail_id) + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert component is ha_comp + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(ha_unique_id) + assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + + assert _dispatch.call_count == len(device[DEV_SIG_ENT_MAP]) + + entity_ids = hass_disable_services.states.async_entity_ids() + await hass_disable_services.async_block_till_done() + + zha_entity_ids = { + ent for ent in entity_ids if ent.split(".")[0] in zha_const.PLATFORMS + } + assert zha_entity_ids == { + e[DEV_SIG_ENT_MAP_ID] for e in device[DEV_SIG_ENT_MAP].values() + } def _get_first_identify_cluster(zigpy_device): @@ -258,31 +287,45 @@ async def test_discover_endpoint(device_info, channels_mock, hass): "homeassistant.components.zha.core.channels.Channels.async_new_entity" ) as new_ent: channels = channels_mock( - device_info["endpoints"], - manufacturer=device_info["manufacturer"], - model=device_info["model"], - node_desc=device_info["node_descriptor"], + device_info[SIG_ENDPOINTS], + manufacturer=device_info[SIG_MANUFACTURER], + model=device_info[SIG_MODEL], + node_desc=device_info[SIG_NODE_DESC], patch_cluster=False, ) - assert device_info["event_channels"] == sorted( + assert device_info[DEV_SIG_EVT_CHANNELS] == sorted( ch.id for pool in channels.pools for ch in pool.client_channels.values() ) - assert new_ent.call_count == len( - [ - device_info - for device_info in device_info["entity_map"].values() - if not device_info.get("default_match", False) - ] - ) + assert new_ent.call_count == len(list(device_info[DEV_SIG_ENT_MAP].values())) - for call_args in new_ent.call_args_list: - comp, ent_cls, unique_id, channels = call_args[0] - map_id = (comp, unique_id) - assert map_id in device_info["entity_map"] - entity_info = device_info["entity_map"][map_id] - assert {ch.name for ch in channels} == set(entity_info["channels"]) - assert ent_cls.__name__ == entity_info["entity_class"] + # build a dict of entity_class -> (component, unique_id, channels) tuple + ha_ent_info = {} + for call in new_ent.call_args_list: + component, entity_cls, unique_id, channels = call[0] + unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) + + for comp_id, ent_info in device_info[DEV_SIG_ENT_MAP].items(): + component, unique_id = comp_id + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert component is ha_comp + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(ha_unique_id) + assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + + assert new_ent.call_count == len(device_info[DEV_SIG_ENT_MAP]) def _ch_mock(cluster): @@ -377,11 +420,11 @@ async def test_device_override( zigpy_device = zigpy_device_mock( { 1: { - "device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT, "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, "00:11:22:33:44:55:66:77", diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index eed0e0b691e..212152e231d 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -49,6 +49,7 @@ from .common import ( get_zha_gateway, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.components.zha.common import async_wait_for_updates @@ -61,9 +62,10 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [hvac.Fan.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [hvac.Fan.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, } } return zigpy_device_mock( @@ -78,9 +80,10 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -99,13 +102,14 @@ async def device_fan_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.Groups.cluster_id, general.OnOff.cluster_id, hvac.Fan.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, }, }, ieee=IEEE_GROUPABLE_DEVICE, @@ -123,14 +127,15 @@ async def device_fan_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.Groups.cluster_id, general.OnOff.cluster_id, hvac.Fan.cluster_id, general.LevelControl.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, }, }, ieee=IEEE_GROUPABLE_DEVICE2, @@ -471,7 +476,7 @@ async def test_fan_update_entity( assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 - assert cluster.read_attributes.await_count == 1 + assert cluster.read_attributes.await_count == 2 await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() @@ -481,7 +486,7 @@ async def test_fan_update_entity( ) assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_OFF - assert cluster.read_attributes.await_count == 2 + assert cluster.read_attributes.await_count == 3 cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} await hass.services.async_call( @@ -492,4 +497,4 @@ async def test_fan_update_entity( assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_LOW assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 - assert cluster.read_attributes.await_count == 3 + assert cluster.read_attributes.await_count == 4 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 4b3a9bec50c..6662f0c2c9f 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -13,6 +13,7 @@ from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.store import TOMBSTONE_LIFETIME from .common import async_enable_traffic, async_find_group_entity_id, get_zha_gateway +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" @@ -24,9 +25,10 @@ def zigpy_dev_basic(zigpy_device_mock): return zigpy_device_mock( { 1: { - "in_clusters": [general.Basic.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, } } ) @@ -47,9 +49,10 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -68,14 +71,15 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE, @@ -92,14 +96,15 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE2, diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index f259febd817..bb8c502562e 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -42,7 +42,7 @@ async def test_migration_from_v1_no_baudrate(hass, config_entry_v1, config): assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] assert CONF_USB_PATH not in config_entry_v1.data - assert config_entry_v1.version == 2 + assert config_entry_v1.version == 3 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -57,7 +57,7 @@ async def test_migration_from_v1_with_baudrate(hass, config_entry_v1): assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 - assert config_entry_v1.version == 2 + assert config_entry_v1.version == 3 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -71,7 +71,7 @@ async def test_migration_from_v1_wrong_baudrate(hass, config_entry_v1): assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] - assert config_entry_v1.version == 2 + assert config_entry_v1.version == 3 @pytest.mark.skipif( diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 915fc77462b..d1a3b5dabb0 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1,6 +1,6 @@ """Test zha light.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, call, patch, sentinel +from unittest.mock import AsyncMock, call, patch, sentinel import pytest import zigpy.profiles.zha as zha @@ -23,6 +23,7 @@ from .common import ( get_zha_gateway, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_fire_time_changed from tests.components.zha.common import async_wait_for_updates @@ -35,39 +36,42 @@ IEEE_GROUPABLE_DEVICE3 = "03:2d:6f:00:0a:90:69:e7" LIGHT_ON_OFF = { 1: { - "device_type": zha.DeviceType.ON_OFF_LIGHT, - "in_clusters": [ + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_INPUT: [ general.Basic.cluster_id, general.Identify.cluster_id, general.OnOff.cluster_id, ], - "out_clusters": [general.Ota.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], } } LIGHT_LEVEL = { 1: { - "device_type": zha.DeviceType.DIMMABLE_LIGHT, - "in_clusters": [ + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.DIMMABLE_LIGHT, + SIG_EP_INPUT: [ general.Basic.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, ], - "out_clusters": [general.Ota.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], } } LIGHT_COLOR = { 1: { - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, - "in_clusters": [ + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [ general.Basic.cluster_id, general.Identify.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, lighting.Color.cluster_id, ], - "out_clusters": [general.Ota.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], } } @@ -79,9 +83,10 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -100,15 +105,16 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, general.Identify.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE, @@ -126,15 +132,16 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, general.Identify.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE2, @@ -152,15 +159,16 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ general.OnOff.cluster_id, general.LevelControl.cluster_id, lighting.Color.cluster_id, general.Groups.cluster_id, general.Identify.cluster_id, ], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, } }, ieee=IEEE_GROUPABLE_DEVICE3, @@ -171,14 +179,14 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined): return zha_device -@patch("zigpy.zcl.clusters.general.OnOff.read_attributes", new=MagicMock()) async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored): """Test zha light platform refresh.""" # create zigpy devices zigpy_device = zigpy_device_mock(LIGHT_ON_OFF) - zha_device = await zha_device_joined_restored(zigpy_device) on_off_cluster = zigpy_device.endpoints[1].on_off + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} + zha_device = await zha_device_joined_restored(zigpy_device) entity_id = await find_entity_id(DOMAIN, zha_device, hass) # allow traffic to flow through the gateway and device @@ -193,7 +201,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored assert hass.states.get(entity_id).state == STATE_OFF # 1 interval - 1 call - on_off_cluster.read_attributes.return_value = [{"on_off": 1}, {}] + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 1} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80)) await hass.async_block_till_done() assert on_off_cluster.read_attributes.call_count == 1 @@ -201,7 +209,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored assert hass.states.get(entity_id).state == STATE_ON # 2 intervals - 2 calls - on_off_cluster.read_attributes.return_value = [{"on_off": 0}, {}] + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80)) await hass.async_block_till_done() assert on_off_cluster.read_attributes.call_count == 2 diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index 72ba0aba9c5..ca9b7961d38 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -11,6 +11,7 @@ from homeassistant.components.lock import DOMAIN from homeassistant.const import STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED from .common import async_enable_traffic, find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro @@ -28,9 +29,9 @@ async def lock(hass, zigpy_device_mock, zha_device_joined_restored): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [closures.DoorLock.cluster_id, general.Basic.cluster_id], - "out_clusters": [], - "device_type": zigpy.profiles.zha.DeviceType.DOOR_LOCK, + SIG_EP_INPUT: [closures.DoorLock.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.DOOR_LOCK, } }, ) diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 1bb9aa947ff..c27cd9fd654 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -16,7 +16,9 @@ from .common import ( async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro @@ -27,9 +29,9 @@ def zigpy_analog_output_device(zigpy_device_mock): endpoints = { 1: { - "device_type": zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, - "in_clusters": [general.AnalogOutput.cluster_id, general.Basic.cluster_id], - "out_clusters": [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, + SIG_EP_INPUT: [general.AnalogOutput.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], } } return zigpy_device_mock(endpoints) @@ -40,25 +42,30 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi cluster = zigpy_analog_output_device.endpoints.get(1).analog_output cluster.PLUGGED_ATTR_READS = { - "present_value": 15.0, "max_present_value": 100.0, - "min_present_value": 0.0, + "min_present_value": 1.0, "relinquish_default": 50.0, - "resolution": 1.0, + "resolution": 1.1, "description": "PWM1", "engineering_units": 98, "application_type": 4 * 0x10000, } + update_attribute_cache(cluster) + cluster.PLUGGED_ATTR_READS["present_value"] = 15.0 + zha_device = await zha_device_joined_restored(zigpy_analog_output_device) # one for present_value and one for the rest configuration attributes - assert cluster.read_attributes.call_count == 2 - assert "max_present_value" in cluster.read_attributes.call_args[0][0] - assert "min_present_value" in cluster.read_attributes.call_args[0][0] - assert "relinquish_default" in cluster.read_attributes.call_args[0][0] - assert "resolution" in cluster.read_attributes.call_args[0][0] - assert "description" in cluster.read_attributes.call_args[0][0] - assert "engineering_units" in cluster.read_attributes.call_args[0][0] - assert "application_type" in cluster.read_attributes.call_args[0][0] + assert cluster.read_attributes.call_count == 3 + attr_reads = set() + for call_args in cluster.read_attributes.call_args_list: + attr_reads |= set(call_args[0][0]) + assert "max_present_value" in attr_reads + assert "min_present_value" in attr_reads + assert "relinquish_default" in attr_reads + assert "resolution" in attr_reads + assert "description" in attr_reads + assert "engineering_units" in attr_reads + assert "application_type" in attr_reads entity_id = await find_entity_id(DOMAIN, zha_device, hass) assert entity_id is not None @@ -68,18 +75,18 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # allow traffic to flow through the gateway and device - assert cluster.read_attributes.call_count == 2 + assert cluster.read_attributes.call_count == 3 await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() - assert cluster.read_attributes.call_count == 4 + assert cluster.read_attributes.call_count == 6 # test that the state has changed from unavailable to 15.0 assert hass.states.get(entity_id).state == "15.0" # test attributes - assert hass.states.get(entity_id).attributes.get("min") == 0.0 + assert hass.states.get(entity_id).attributes.get("min") == 1.0 assert hass.states.get(entity_id).attributes.get("max") == 100.0 - assert hass.states.get(entity_id).attributes.get("step") == 1.0 + assert hass.states.get(entity_id).attributes.get("step") == 1.1 assert hass.states.get(entity_id).attributes.get("icon") == "mdi:percent" assert hass.states.get(entity_id).attributes.get("unit_of_measurement") == "%" assert ( @@ -88,7 +95,7 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi ) # change value from device - assert cluster.read_attributes.call_count == 4 + assert cluster.read_attributes.call_count == 6 await send_attributes_report(hass, cluster, {0x0055: 15}) assert hass.states.get(entity_id).state == "15.0" @@ -110,10 +117,10 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi cluster.PLUGGED_ATTR_READS["present_value"] = 30.0 # test rejoin - assert cluster.read_attributes.call_count == 4 + assert cluster.read_attributes.call_count == 6 await async_test_rejoin(hass, zigpy_analog_output_device, [cluster], (1,)) assert hass.states.get(entity_id).state == "30.0" - assert cluster.read_attributes.call_count == 6 + assert cluster.read_attributes.call_count == 9 # update device value with failed attribute report cluster.PLUGGED_ATTR_READS["present_value"] = 40.0 @@ -127,5 +134,5 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True ) assert hass.states.get(entity_id).state == "40.0" - assert cluster.read_attributes.call_count == 7 + assert cluster.read_attributes.call_count == 10 assert "present_value" in cluster.read_attributes.call_args[0][0] diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index b0f1a44a3d6..d202c7256dd 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -314,3 +314,74 @@ def test_weighted_match(channel, entity_registry, manufacturer, model, match_nam assert match.__name__ == match_name assert claimed == [ch_on_off] + + +def test_multi_sensor_match(channel, entity_registry): + """Test multi-entity match.""" + + s = mock.sentinel + + @entity_registry.multipass_match( + s.binary_sensor, + channel_names="smartenergy_metering", + ) + class SmartEnergySensor2: + pass + + ch_se = channel("smartenergy_metering", 0x0702) + ch_illuminati = channel("illuminance", 0x0401) + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_illuminati, + aux_channels=[ch_se, ch_illuminati], + ) + + assert s.binary_sensor not in match + assert s.component not in match + assert set(claimed) == set() + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_se, + aux_channels=[ch_se, ch_illuminati], + ) + + assert s.binary_sensor in match + assert s.component not in match + assert set(claimed) == {ch_se} + assert {cls.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__ + } + + @entity_registry.multipass_match( + s.component, channel_names="smartenergy_metering", aux_channels="illuminance" + ) + class SmartEnergySensor1: + pass + + @entity_registry.multipass_match( + s.binary_sensor, + channel_names="smartenergy_metering", + aux_channels="illuminance", + ) + class SmartEnergySensor3: + pass + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_se, + aux_channels={ch_se, ch_illuminati}, + ) + + assert s.binary_sensor in match + assert s.component in match + assert set(claimed) == {ch_se, ch_illuminati} + assert {cls.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__, + SmartEnergySensor3.__name__, + } + assert {cls.__name__ for cls in match[s.component]} == {SmartEnergySensor1.__name__} diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index b6b4b343e3b..21731da72e6 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -11,10 +11,13 @@ import zigpy.zcl.clusters.smartenergy as smartenergy from homeassistant.components.sensor import DOMAIN import homeassistant.config as config_util from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, @@ -23,6 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.helpers import restore_state from homeassistant.util import dt as dt_util @@ -31,9 +36,13 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, + find_entity_ids, send_attribute_report, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + +ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_e769900a_{}" async def async_test_humidity(hass, cluster, entity_id): @@ -64,9 +73,38 @@ async def async_test_illuminance(hass, cluster, entity_id): async def async_test_metering(hass, cluster, entity_id): - """Test metering sensor.""" + """Test Smart Energy metering sensor.""" await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100}) - assert_state(hass, entity_id, "12345.0", "unknown") + assert_state(hass, entity_id, "12345.0", None) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + + await send_attributes_report(hass, cluster, {1024: 12346, "status": 64 + 8}) + assert_state(hass, entity_id, "12346.0", None) + assert ( + hass.states.get(entity_id).attributes["status"] + == "SERVICE_DISCONNECT|POWER_FAILURE" + ) + + await send_attributes_report( + hass, cluster, {"status": 32, "metering_device_type": 1} + ) + # currently only statuses for electric meters are supported + assert hass.states.get(entity_id).attributes["status"] == "" + + +async def async_test_smart_energy_summation(hass, cluster, entity_id): + """Test SmartEnergy Summation delivered sensro.""" + + await send_attributes_report( + hass, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} + ) + assert_state(hass, entity_id, "12.32", VOLUME_CUBIC_METERS) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + assert ( + hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + ) async def async_test_electrical_measurement(hass, cluster, entity_id): @@ -105,40 +143,81 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): @pytest.mark.parametrize( - "cluster_id, test_func, report_count, read_plug", + "cluster_id, entity_suffix, test_func, report_count, read_plug, unsupported_attrs", ( - (measurement.RelativeHumidity.cluster_id, async_test_humidity, 1, None), + ( + measurement.RelativeHumidity.cluster_id, + "humidity", + async_test_humidity, + 1, + None, + None, + ), ( measurement.TemperatureMeasurement.cluster_id, + "temperature", async_test_temperature, 1, None, + None, + ), + ( + measurement.PressureMeasurement.cluster_id, + "pressure", + async_test_pressure, + 1, + None, + None, ), - (measurement.PressureMeasurement.cluster_id, async_test_pressure, 1, None), ( measurement.IlluminanceMeasurement.cluster_id, + "illuminance", async_test_illuminance, 1, None, + None, ), ( smartenergy.Metering.cluster_id, + "smartenergy_metering", async_test_metering, 1, { "demand_formatting": 0xF9, "divisor": 1, + "metering_device_type": 0x00, "multiplier": 1, + "status": 0x00, }, + {"current_summ_delivered"}, + ), + ( + smartenergy.Metering.cluster_id, + "smartenergy_metering_summation_delivered", + async_test_smart_energy_summation, + 1, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summa_formatting": 0b1_0111_010, + "unit_of_measure": 0x01, + }, + {"instaneneous_demand"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement", async_test_electrical_measurement, 1, None, + None, ), ( general.PowerConfiguration.cluster_id, + "power", async_test_powerconfiguration, 2, { @@ -146,6 +225,7 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): "battery_voltage": 29, "battery_quantity": 3, }, + None, ), ), ) @@ -154,28 +234,33 @@ async def test_sensor( zigpy_device_mock, zha_device_joined_restored, cluster_id, + entity_suffix, test_func, report_count, read_plug, + unsupported_attrs, ): """Test zha sensor platform.""" zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [cluster_id, general.Basic.cluster_id], - "out_cluster": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + if unsupported_attrs: + for attr in unsupported_attrs: + cluster.add_unsupported_attribute(attr) if cluster_id == smartenergy.Metering.cluster_id: # this one is mains powered zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 cluster.PLUGGED_ATTR_READS = read_plug zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) await async_enable_traffic(hass, [zha_device], enabled=False) await hass.async_block_till_done() @@ -284,12 +369,12 @@ async def test_temp_uom( zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [ + SIG_EP_INPUT: [ measurement.TemperatureMeasurement.cluster_id, general.Basic.cluster_id, ], - "out_cluster": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } ) @@ -327,9 +412,9 @@ async def test_electrical_measurement_init( zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [cluster_id, general.Basic.cluster_id], - "out_cluster": [], - "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } ) @@ -371,3 +456,197 @@ async def test_electrical_measurement_init( assert channel.divisor == 10 assert channel.multiplier == 20 assert hass.states.get(entity_id).state == "60.0" + + +@pytest.mark.parametrize( + "cluster_id, unsupported_attributes, entity_ids, missing_entity_ids", + ( + ( + smartenergy.Metering.cluster_id, + { + "instantaneous_demand", + }, + { + "smartenergy_metering_summation_delivered", + }, + { + "smartenergy_metering", + }, + ), + ( + smartenergy.Metering.cluster_id, + {"instantaneous_demand", "current_summ_delivered"}, + {}, + { + "smartenergy_metering_summation_delivered", + "smartenergy_metering", + }, + ), + ( + smartenergy.Metering.cluster_id, + {}, + { + "smartenergy_metering_summation_delivered", + "smartenergy_metering", + }, + {}, + ), + ), +) +async def test_unsupported_attributes_sensor( + hass, + zigpy_device_mock, + zha_device_joined_restored, + cluster_id, + unsupported_attributes, + entity_ids, + missing_entity_ids, +): + """Test zha sensor platform.""" + + entity_ids = {ENTITY_ID_PREFIX.format(e) for e in entity_ids} + missing_entity_ids = {ENTITY_ID_PREFIX.format(e) for e in missing_entity_ids} + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + } + } + ) + cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + if cluster_id == smartenergy.Metering.cluster_id: + # this one is mains powered + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + for attr in unsupported_attributes: + cluster.add_unsupported_attribute(attr) + zha_device = await zha_device_joined_restored(zigpy_device) + + await async_enable_traffic(hass, [zha_device], enabled=False) + await hass.async_block_till_done() + present_entity_ids = set(await find_entity_ids(DOMAIN, zha_device, hass)) + assert present_entity_ids == entity_ids + assert missing_entity_ids not in present_entity_ids + + +@pytest.mark.parametrize( + "raw_uom, raw_value, expected_state, expected_uom", + ( + ( + 1, + 12320, + "1.23", + VOLUME_CUBIC_METERS, + ), + ( + 1, + 1232000, + "123.20", + VOLUME_CUBIC_METERS, + ), + ( + 3, + 2340, + "0.23", + f"100 {VOLUME_CUBIC_FEET}", + ), + ( + 3, + 2360, + "0.24", + f"100 {VOLUME_CUBIC_FEET}", + ), + ( + 8, + 23660, + "2.37", + "kPa", + ), + ( + 0, + 9366, + "0.937", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 999, + "0.1", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 10091, + "1.009", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 10099, + "1.01", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 100999, + "10.1", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 100023, + "10.002", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 102456, + "10.246", + ENERGY_KILO_WATT_HOUR, + ), + ), +) +async def test_se_summation_uom( + hass, + zigpy_device_mock, + zha_device_joined, + raw_uom, + raw_value, + expected_state, + expected_uom, +): + """Test zha smart energy summation.""" + + entity_id = ENTITY_ID_PREFIX.format("smartenergy_metering_summation_delivered") + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + smartenergy.Metering.cluster_id, + general.Basic.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SIMPLE_SENSOR, + } + } + ) + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + + cluster = zigpy_device.endpoints[1].in_clusters[smartenergy.Metering.cluster_id] + for attr in ("instanteneous_demand",): + cluster.add_unsupported_attribute(attr) + cluster.PLUGGED_ATTR_READS = { + "current_summ_delivered": raw_value, + "demand_formatting": 0xF9, + "divisor": 10000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summa_formatting": 0b1_0111_010, + "unit_of_measure": raw_uom, + } + await zha_device_joined(zigpy_device) + + assert_state(hass, entity_id, expected_state, expected_uom) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 4cec0753c68..04f43344b98 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -18,6 +18,7 @@ from .common import ( get_zha_gateway, send_attributes_report, ) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro from tests.components.zha.common import async_wait_for_updates @@ -33,9 +34,9 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - "in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, } } return zigpy_device_mock(endpoints) @@ -48,9 +49,9 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [], - "out_clusters": [], - "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, } }, ieee="00:15:8d:00:02:32:4f:32", @@ -69,9 +70,9 @@ async def device_switch_1(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.OnOff.cluster_id, general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, } }, ieee=IEEE_GROUPABLE_DEVICE, @@ -89,9 +90,9 @@ async def device_switch_2(hass, zigpy_device_mock, zha_device_joined): zigpy_device = zigpy_device_mock( { 1: { - "in_clusters": [general.OnOff.cluster_id, general.Groups.cluster_id], - "out_clusters": [], - "device_type": zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, } }, ieee=IEEE_GROUPABLE_DEVICE2, diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 1ea52d4e604..531e9649ec3 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1,1022 +1,1081 @@ """Example Zigbee Devices.""" +from zigpy.const import ( + SIG_ENDPOINTS, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, + SIG_MANUFACTURER, + SIG_MODEL, + SIG_NODE_DESC, +) + +DEV_SIG_CHANNELS = "channels" +DEV_SIG_DEV_NO = "device_no" +DEV_SIG_ENTITIES = "entities" +DEV_SIG_ENT_MAP = "entity_map" +DEV_SIG_ENT_MAP_CLASS = "entity_class" +DEV_SIG_ENT_MAP_ID = "entity_id" +DEV_SIG_EP_ID = "endpoint_id" +DEV_SIG_EVT_CHANNELS = "event_channels" +DEV_SIG_ZHA_QUIRK = "zha_quirk" + DEVICES = [ { - "device_no": 0, - "endpoints": { + DEV_SIG_DEV_NO: 0, + SIG_ENDPOINTS: { 1: { - "device_type": 2080, - "endpoint_id": 1, - "in_clusters": [0, 3, 4096, 64716], - "out_clusters": [3, 4, 6, 8, 4096, 64716], - "profile_id": 260, + SIG_EP_TYPE: 2080, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4096, 64716], + SIG_EP_OUTPUT: [3, 4, 6, 8, 4096, 64716], + SIG_EP_PROFILE: 260, } }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008"], - "manufacturer": "ADUROLIGHT", - "model": "Adurolight_NCC", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", - "zha_quirks": "AdurolightNCC", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008"], + SIG_MANUFACTURER: "ADUROLIGHT", + SIG_MODEL: "Adurolight_NCC", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + DEV_SIG_ZHA_QUIRK: "AdurolightNCC", }, { - "device_no": 1, - "endpoints": { + DEV_SIG_DEV_NO: 1, + SIG_ENDPOINTS: { 5: { - "device_type": 1026, - "endpoint_id": 5, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", "sensor.bosch_isw_zpr1_wp13_77665544_power", "sensor.bosch_isw_zpr1_wp13_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-5-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.bosch_isw_zpr1_wp13_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-5-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.bosch_isw_zpr1_wp13_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", }, }, - "event_channels": ["5:0x0019"], - "manufacturer": "Bosch", - "model": "ISW-ZPR1-WP13", - "node_descriptor": b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", + DEV_SIG_EVT_CHANNELS: ["5:0x0019"], + SIG_MANUFACTURER: "Bosch", + SIG_MODEL: "ISW-ZPR1-WP13", + SIG_NODE_DESC: b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", }, { - "device_no": 2, - "endpoints": { + DEV_SIG_DEV_NO: 2, + SIG_ENDPOINTS: { 1: { - "device_type": 1, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 2821], - "out_clusters": [3, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 2821], + SIG_EP_OUTPUT: [3, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.centralite_3130_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.centralite_3130_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3130_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "CentraLite", - "model": "3130", - "node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3130", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3130", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3130", }, { - "device_no": 3, - "endpoints": { + DEV_SIG_DEV_NO: 3, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.centralite_3210_l_77665544_electrical_measurement", "sensor.centralite_3210_l_77665544_smartenergy_metering", + "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", "switch.centralite_3210_l_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.centralite_3210_l_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.centralite_3210_l_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.centralite_3210_l_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.centralite_3210_l_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3210-L", - "node_descriptor": b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3210-L", + SIG_NODE_DESC: b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 4, - "endpoints": { + DEV_SIG_DEV_NO: 4, + SIG_ENDPOINTS: { 1: { - "device_type": 770, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 2821, 64581], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 770, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 2821, 64581], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.centralite_3310_s_77665544_manufacturer_specific", "sensor.centralite_3310_s_77665544_power", "sensor.centralite_3310_s_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3310_s_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3310_s_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-64581"): { - "channels": ["manufacturer_specific"], - "entity_class": "Humidity", - "entity_id": "sensor.centralite_3310_s_77665544_manufacturer_specific", + DEV_SIG_CHANNELS: ["manufacturer_specific"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_manufacturer_specific", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3310-S", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3310S", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3310-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3310S", }, { - "device_no": 5, - "endpoints": { + DEV_SIG_DEV_NO: 5, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 12, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821, 64527], - "out_clusters": [3], - "profile_id": 49887, + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64527], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_3315_s_77665544_ias_zone", "sensor.centralite_3315_s_77665544_power", "sensor.centralite_3315_s_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3315_s_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3315_s_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_3315_s_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3315-S", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLiteIASSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3315-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLiteIASSensor", }, { - "device_no": 6, - "endpoints": { + DEV_SIG_DEV_NO: 6, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 12, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821, 64527], - "out_clusters": [3], - "profile_id": 49887, + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64527], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_3320_l_77665544_ias_zone", "sensor.centralite_3320_l_77665544_power", "sensor.centralite_3320_l_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3320_l_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3320_l_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_3320_l_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3320-L", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLiteIASSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3320-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLiteIASSensor", }, { - "device_no": 7, - "endpoints": { + DEV_SIG_DEV_NO: 7, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 263, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821, 64582], - "out_clusters": [3], - "profile_id": 49887, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64582], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_3326_l_77665544_ias_zone", "sensor.centralite_3326_l_77665544_power", "sensor.centralite_3326_l_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_3326_l_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_3326_l_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_3326_l_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "3326-L", - "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLiteMotionSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3326-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLiteMotionSensor", }, { - "device_no": 8, - "endpoints": { + DEV_SIG_DEV_NO: 8, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 263, - "endpoint_id": 2, - "in_clusters": [0, 3, 1030, 2821], - "out_clusters": [3], - "profile_id": 260, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 1030, 2821], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", "sensor.centralite_motion_sensor_a_77665544_power", "sensor.centralite_motion_sensor_a_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.centralite_motion_sensor_a_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.centralite_motion_sensor_a_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { - "channels": ["occupancy"], - "entity_class": "Occupancy", - "entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", + DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "CentraLite", - "model": "Motion Sensor-A", - "node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3305S", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "Motion Sensor-A", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3305S", }, { - "device_no": 9, - "endpoints": { + DEV_SIG_DEV_NO: 9, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 1794], - "out_clusters": [0], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, }, 4: { - "device_type": 9, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 9, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["4:0x0019"], - "manufacturer": "ClimaxTechnology", - "model": "PSMP5_00.00.02.02TC", - "node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["4:0x0019"], + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "PSMP5_00.00.02.02TC", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 10, - "endpoints": { + DEV_SIG_DEV_NO: 10, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 3, 1280, 1282], - "out_clusters": [0], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 1280, 1282], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone", } }, - "event_channels": [], - "manufacturer": "ClimaxTechnology", - "model": "SD8SC_00.00.03.12TC", - "node_descriptor": b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "SD8SC_00.00.03.12TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 11, - "endpoints": { + DEV_SIG_DEV_NO: 11, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 3, 1280], - "out_clusters": [0], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 1280], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone", } }, - "event_channels": [], - "manufacturer": "ClimaxTechnology", - "model": "WS15_00.00.03.03TC", - "node_descriptor": b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "WS15_00.00.03.03TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 12, - "endpoints": { + DEV_SIG_DEV_NO: 12, + SIG_ENDPOINTS: { 11: { - "device_type": 528, - "endpoint_id": 11, - "in_clusters": [0, 3, 4, 5, 6, 8, 768], - "out_clusters": [], - "profile_id": 49246, + SIG_EP_TYPE: 528, + DEV_SIG_EP_ID: 11, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49246, }, 13: { - "device_type": 57694, - "endpoint_id": 13, - "in_clusters": [4096], - "out_clusters": [4096], - "profile_id": 49246, + SIG_EP_TYPE: 57694, + DEV_SIG_EP_ID: 13, + SIG_EP_INPUT: [4096], + SIG_EP_OUTPUT: [4096], + SIG_EP_PROFILE: 49246, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-11"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off", } }, - "event_channels": [], - "manufacturer": "Feibit Inc co.", - "model": "FB56-ZCW08KU1.1", - "node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "Feibit Inc co.", + SIG_MODEL: "FB56-ZCW08KU1.1", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 13, - "endpoints": { + DEV_SIG_DEV_NO: 13, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1280, 1282], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1280, 1282], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", "sensor.heiman_smokesensor_em_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.heiman_smokesensor_em_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "HEIMAN", - "model": "SmokeSensor-EM", - "node_descriptor": b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "HEIMAN", + SIG_MODEL: "SmokeSensor-EM", + SIG_NODE_DESC: b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", }, { - "device_no": 14, - "endpoints": { + DEV_SIG_DEV_NO: 14, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["binary_sensor.heiman_co_v16_77665544_ias_zone"], - "entity_map": { + DEV_SIG_ENTITIES: ["binary_sensor.heiman_co_v16_77665544_ias_zone"], + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.heiman_co_v16_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_77665544_ias_zone", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "Heiman", - "model": "CO_V16", - "node_descriptor": b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "CO_V16", + SIG_NODE_DESC: b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", }, { - "device_no": 15, - "endpoints": { + DEV_SIG_DEV_NO: 15, + SIG_ENDPOINTS: { 1: { - "device_type": 1027, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 9, 1280, 1282], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1027, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 9, 1280, 1282], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["binary_sensor.heiman_warningdevice_77665544_ias_zone"], - "entity_map": { + DEV_SIG_ENTITIES: ["binary_sensor.heiman_warningdevice_77665544_ias_zone"], + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.heiman_warningdevice_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_77665544_ias_zone", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "Heiman", - "model": "WarningDevice", - "node_descriptor": b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "WarningDevice", + SIG_NODE_DESC: b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", }, { - "device_no": 16, - "endpoints": { + DEV_SIG_DEV_NO: 16, + SIG_ENDPOINTS: { 6: { - "device_type": 1026, - "endpoint_id": 6, - "in_clusters": [0, 1, 3, 32, 1024, 1026, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.hivehome_com_mot003_77665544_ias_zone", "sensor.hivehome_com_mot003_77665544_illuminance", "sensor.hivehome_com_mot003_77665544_power", "sensor.hivehome_com_mot003_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-6-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.hivehome_com_mot003_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-6-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.hivehome_com_mot003_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-6-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.hivehome_com_mot003_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.hivehome_com_mot003_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_77665544_ias_zone", }, }, - "event_channels": ["6:0x0019"], - "manufacturer": "HiveHome.com", - "model": "MOT003", - "node_descriptor": b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", - "zha_quirks": "MOT003", + DEV_SIG_EVT_CHANNELS: ["6:0x0019"], + SIG_MANUFACTURER: "HiveHome.com", + SIG_MODEL: "MOT003", + SIG_NODE_DESC: b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", + DEV_SIG_ZHA_QUIRK: "MOT003", }, { - "device_no": 17, - "endpoints": { + DEV_SIG_DEV_NO: 17, + SIG_ENDPOINTS: { 1: { - "device_type": 268, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 4096, 64636], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 260, + SIG_EP_TYPE: 268, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 4096, 64636], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 260, }, 242: { - "device_type": 97, - "endpoint_id": 242, - "in_clusters": [33], - "out_clusters": [33], - "profile_id": 41440, + SIG_EP_TYPE: 97, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [33], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E12 WS opal 600lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E12 WS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", }, { - "device_no": 18, - "endpoints": { + DEV_SIG_DEV_NO: 18, + SIG_ENDPOINTS: { 1: { - "device_type": 512, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 512, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 CWS opal 600lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 CWS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 19, - "endpoints": { + DEV_SIG_DEV_NO: 19, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 W opal 1000lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 W opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 20, - "endpoints": { + DEV_SIG_DEV_NO: 20, + SIG_ENDPOINTS: { 1: { - "device_type": 544, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 544, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 WS opal 980lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 WS opal 980lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 21, - "endpoints": { + DEV_SIG_DEV_NO: 21, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 2821, 4096], - "out_clusters": [5, 25, 32, 4096], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI bulb E26 opal 1000lm", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 22, - "endpoints": { + DEV_SIG_DEV_NO: 22, + SIG_ENDPOINTS: { 1: { - "device_type": 266, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 64636], - "out_clusters": [5, 25, 32], - "profile_id": 260, + SIG_EP_TYPE: 266, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 64636], + SIG_EP_OUTPUT: [5, 25, 32], + SIG_EP_PROFILE: 260, } }, - "entities": ["switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off" + ], + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", } }, - "event_channels": ["1:0x0005", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI control outlet", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", - "zha_quirks": "TradfriPlug", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI control outlet", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "TradfriPlug", }, { - "device_no": 23, - "endpoints": { + DEV_SIG_DEV_NO: 23, + SIG_ENDPOINTS: { 1: { - "device_type": 2128, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 2821, 4096], - "out_clusters": [3, 4, 6, 25, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 2128, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 6, 25, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Motion", - "entity_id": "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Motion", + DEV_SIG_ENT_MAP_ID: "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", }, }, - "event_channels": ["1:0x0006", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI motion sensor", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "IkeaTradfriMotion", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI motion sensor", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "IkeaTradfriMotion", }, { - "device_no": 24, - "endpoints": { + DEV_SIG_DEV_NO: 24, + SIG_ENDPOINTS: { 1: { - "device_type": 2080, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 32, 4096, 64636], - "out_clusters": [3, 4, 6, 8, 25, 258, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2080, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 32, 4096, 64636], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 258, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power" + ], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI on/off switch", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", - "zha_quirks": "IkeaTradfriRemote2Btn", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI on/off switch", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "IkeaTradfriRemote2Btn", }, { - "device_no": 25, - "endpoints": { + DEV_SIG_DEV_NO: 25, + SIG_ENDPOINTS: { 1: { - "device_type": 2096, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 2821, 4096], - "out_clusters": [3, 4, 5, 6, 8, 25, 4096], - "profile_id": 49246, + SIG_EP_TYPE: 2096, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 4096], + SIG_EP_PROFILE: 49246, } }, - "entities": ["sensor.ikea_of_sweden_tradfri_remote_control_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power" + ], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", } }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI remote control", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "IkeaTradfriRemote", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI remote control", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "IkeaTradfriRemote", }, { - "device_no": 26, - "endpoints": { + DEV_SIG_DEV_NO: 26, + SIG_ENDPOINTS: { 1: { - "device_type": 8, - "endpoint_id": 1, - "in_clusters": [0, 3, 9, 2821, 4096, 64636], - "out_clusters": [25, 32, 4096], - "profile_id": 260, + SIG_EP_TYPE: 8, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 9, 2821, 4096, 64636], + SIG_EP_OUTPUT: [25, 32, 4096], + SIG_EP_PROFILE: 260, }, 242: { - "device_type": 97, - "endpoint_id": 242, - "in_clusters": [33], - "out_clusters": [33], - "profile_id": 41440, + SIG_EP_TYPE: 97, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [33], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI signal repeater", - "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI signal repeater", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", }, { - "device_no": 27, - "endpoints": { + DEV_SIG_DEV_NO: 27, + SIG_ENDPOINTS: { 1: { - "device_type": 2064, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 9, 2821, 4096], - "out_clusters": [3, 4, 6, 8, 25, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power" + ], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI wireless dimmer", - "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI wireless dimmer", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 28, - "endpoints": { + DEV_SIG_DEV_NO: 28, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 260, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821], - "out_clusters": [3, 6, 8], - "profile_id": 260, + SIG_EP_TYPE: 260, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6, 8], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.jasco_products_45852_77665544_level_on_off", "sensor.jasco_products_45852_77665544_smartenergy_metering", + "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.jasco_products_45852_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.jasco_products_45852_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["1:0x0019", "2:0x0006", "2:0x0008"], - "manufacturer": "Jasco Products", - "model": "45852", - "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45852", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 29, - "endpoints": { + DEV_SIG_DEV_NO: 29, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 1794, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.jasco_products_45856_77665544_on_off", "sensor.jasco_products_45856_77665544_smartenergy_metering", + "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.jasco_products_45856_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.jasco_products_45856_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["1:0x0019", "2:0x0006"], - "manufacturer": "Jasco Products", - "model": "45856", - "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45856", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 30, - "endpoints": { + DEV_SIG_DEV_NO: 30, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 260, - "endpoint_id": 2, - "in_clusters": [0, 3, 2821], - "out_clusters": [3, 6, 8], - "profile_id": 260, + SIG_EP_TYPE: 260, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6, 8], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.jasco_products_45857_77665544_level_on_off", "sensor.jasco_products_45857_77665544_smartenergy_metering", + "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.jasco_products_45857_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.jasco_products_45857_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["1:0x0019", "2:0x0006", "2:0x0008"], - "manufacturer": "Jasco Products", - "model": "45857", - "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45857", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 31, - "endpoints": { + DEV_SIG_DEV_NO: 31, + SIG_ENDPOINTS: { 1: { - "device_type": 3, - "endpoint_id": 1, - "in_clusters": [ + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [ 0, 1, 3, @@ -1031,50 +1090,50 @@ DEVICES = [ 64513, 64514, ], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "KeenVent", - "entity_id": "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Keen Home Inc", - "model": "SV02-610-MP-1.3", - "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-610-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", }, { - "device_no": 32, - "endpoints": { + DEV_SIG_DEV_NO: 32, + SIG_ENDPOINTS: { 1: { - "device_type": 3, - "endpoint_id": 1, - "in_clusters": [ + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [ 0, 1, 3, @@ -1089,50 +1148,50 @@ DEVICES = [ 64513, 64514, ], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "KeenVent", - "entity_id": "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Keen Home Inc", - "model": "SV02-612-MP-1.2", - "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.2", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", }, { - "device_no": 33, - "endpoints": { + DEV_SIG_DEV_NO: 33, + SIG_ENDPOINTS: { 1: { - "device_type": 3, - "endpoint_id": 1, - "in_clusters": [ + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [ 0, 1, 3, @@ -1147,1463 +1206,1472 @@ DEVICES = [ 64513, 64514, ], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "KeenVent", - "entity_id": "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Keen Home Inc", - "model": "SV02-612-MP-1.3", - "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", - "zha_quirks": "KeenHomeSmartVent", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + DEV_SIG_ZHA_QUIRK: "KeenHomeSmartVent", }, { - "device_no": 34, - "endpoints": { + DEV_SIG_DEV_NO: 34, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 514], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 514], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { - "channels": ["fan"], - "entity_class": "ZhaFan", - "entity_id": "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", + DEV_SIG_CHANNELS: ["fan"], + DEV_SIG_ENT_MAP_CLASS: "ZhaFan", + DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "King Of Fans, Inc.", - "model": "HBUniversalCFRemote", - "node_descriptor": b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CeilingFan", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "King Of Fans, Inc.", + SIG_MODEL: "HBUniversalCFRemote", + SIG_NODE_DESC: b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CeilingFan", }, { - "device_no": 35, - "endpoints": { + DEV_SIG_DEV_NO: 35, + SIG_ENDPOINTS: { 1: { - "device_type": 2048, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4096, 64769], - "out_clusters": [3, 4, 6, 8, 25, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2048, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4096, 64769], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 768, 4096], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lds_zbt_cctswitch_d0001_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lds_zbt_cctswitch_d0001_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lds_zbt_cctswitch_d0001_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], - "manufacturer": "LDS", - "model": "ZBT-CCTSwitch-D0001", - "node_descriptor": b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", - "zha_quirks": "CCTSwitch", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], + SIG_MANUFACTURER: "LDS", + SIG_MODEL: "ZBT-CCTSwitch-D0001", + SIG_NODE_DESC: b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CCTSwitch", }, { - "device_no": 36, - "endpoints": { + DEV_SIG_DEV_NO: 36, + SIG_ENDPOINTS: { 1: { - "device_type": 258, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.ledvance_a19_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["light.ledvance_a19_rgbw_77665544_level_light_color_on_off"], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ledvance_a19_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_a19_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "A19 RGBW", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 37, - "endpoints": { + DEV_SIG_DEV_NO: 37, + SIG_ENDPOINTS: { 1: { - "device_type": 258, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.ledvance_flex_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "light.ledvance_flex_rgbw_77665544_level_light_color_on_off" + ], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ledvance_flex_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_flex_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "FLEX RGBW", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "FLEX RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 38, - "endpoints": { + DEV_SIG_DEV_NO: 38, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 2821, 64513, 64520], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2821, 64513, 64520], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["switch.ledvance_plug_77665544_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["switch.ledvance_plug_77665544_on_off"], + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.ledvance_plug_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.ledvance_plug_77665544_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "PLUG", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "PLUG", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 39, - "endpoints": { + DEV_SIG_DEV_NO: 39, + SIG_ENDPOINTS: { 1: { - "device_type": 258, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.ledvance_rt_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["light.ledvance_rt_rgbw_77665544_level_light_color_on_off"], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.ledvance_rt_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_rt_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["1:0x0019"], - "manufacturer": "LEDVANCE", - "model": "RT RGBW", - "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "RT RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 40, - "endpoints": { + DEV_SIG_DEV_NO: 40, + SIG_ENDPOINTS: { 1: { - "device_type": 81, - "endpoint_id": 1, - "in_clusters": [0, 1, 2, 3, 4, 5, 6, 10, 16, 2820], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 4, 5, 6, 10, 16, 2820], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 9, - "endpoint_id": 2, - "in_clusters": [12], - "out_clusters": [4, 12], - "profile_id": 260, + SIG_EP_TYPE: 9, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [12], + SIG_EP_OUTPUT: [4, 12], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 83, - "endpoint_id": 3, - "in_clusters": [12], - "out_clusters": [12], - "profile_id": 260, + SIG_EP_TYPE: 83, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [12], + SIG_EP_OUTPUT: [12], + SIG_EP_PROFILE: 260, }, 100: { - "device_type": 263, - "endpoint_id": 100, - "in_clusters": [15], - "out_clusters": [4, 15], - "profile_id": 260, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 100, + SIG_EP_INPUT: [15], + SIG_EP_OUTPUT: [4, 15], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.lumi_lumi_plug_maus01_77665544_analog_input", "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", "switch.lumi_lumi_plug_maus01_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-2-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input", }, ("sensor", "00:11:22:33:44:55:66:77-3-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", }, ("switch", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.lumi_lumi_plug_maus01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.lumi_lumi_plug_maus01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-100-15"): { + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_plug_maus01_77665544_binary_input", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.plug.maus01", - "node_descriptor": b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Plug", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.plug.maus01", + SIG_NODE_DESC: b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Plug", }, { - "device_no": 41, - "endpoints": { + DEV_SIG_DEV_NO: 41, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 1, 2, 3, 4, 5, 6, 10, 12, 16, 2820], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 4, 5, 6, 10, 12, 16, 2820], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 257, - "endpoint_id": 2, - "in_clusters": [4, 5, 6, 16], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [4, 5, 6, 16], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.lumi_lumi_relay_c2acn01_77665544_on_off", "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", }, ("light", "00:11:22:33:44:55:66:77-2"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.relay.c2acn01", - "node_descriptor": b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Relay", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.relay.c2acn01", + SIG_NODE_DESC: b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Relay", }, { - "device_no": 42, - "endpoints": { + DEV_SIG_DEV_NO: 42, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 18, 25, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 12, 18], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_remote_b186acn01_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_remote_b186acn01_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.remote.b186acn01", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "RemoteB186ACN01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b186acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "RemoteB186ACN01", }, { - "device_no": 43, - "endpoints": { + DEV_SIG_DEV_NO: 43, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 18, 25, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 12, 18], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_remote_b286acn01_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_remote_b286acn01_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.remote.b286acn01", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "RemoteB286ACN01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "RemoteB286ACN01", }, { - "device_no": 44, - "endpoints": { + DEV_SIG_DEV_NO: 44, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": -1, - "endpoint_id": 2, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 3: { - "device_type": -1, - "endpoint_id": 3, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 4: { - "device_type": -1, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 5: { - "device_type": -1, - "endpoint_id": 5, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 6: { - "device_type": -1, - "endpoint_id": 6, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"], - "manufacturer": "LUMI", - "model": "lumi.remote.b286opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 45, - "endpoints": { + DEV_SIG_DEV_NO: 45, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": -1, - "endpoint_id": 3, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 4: { - "device_type": -1, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 5: { - "device_type": -1, - "endpoint_id": 5, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, 6: { - "device_type": -1, - "endpoint_id": 6, - "in_clusters": [], - "out_clusters": [], - "profile_id": -1, + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.remote.b486opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b486opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 46, - "endpoints": { + DEV_SIG_DEV_NO: 46, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, } }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"], - "manufacturer": "LUMI", - "model": "lumi.remote.b686opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 47, - "endpoints": { + DEV_SIG_DEV_NO: 47, + SIG_ENDPOINTS: { 1: { - "device_type": 261, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6, 8, 768], - "profile_id": 260, + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": None, - "endpoint_id": 3, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, 4: { - "device_type": None, - "endpoint_id": 4, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, 5: { - "device_type": None, - "endpoint_id": 5, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, 6: { - "device_type": None, - "endpoint_id": 6, - "in_clusters": [], - "out_clusters": [], - "profile_id": None, + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, }, }, - "entities": [], - "entity_map": {}, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.remote.b686opcn01", - "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 48, - "endpoints": { + DEV_SIG_DEV_NO: 48, + SIG_ENDPOINTS: { 8: { - "device_type": 256, - "endpoint_id": 8, - "in_clusters": [0, 6], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - "channels": ["on_off", "on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-8"): { - "channels": ["on_off", "on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, }, - "event_channels": ["8:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.router", - "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 49, - "endpoints": { + DEV_SIG_DEV_NO: 49, + SIG_ENDPOINTS: { 8: { - "device_type": 256, - "endpoint_id": 8, - "in_clusters": [0, 6, 11, 17], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6, 11, 17], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - "channels": ["on_off", "on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-8"): { - "channels": ["on_off", "on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, }, - "event_channels": ["8:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.router", - "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 50, - "endpoints": { + DEV_SIG_DEV_NO: 50, + SIG_ENDPOINTS: { 8: { - "device_type": 256, - "endpoint_id": 8, - "in_clusters": [0, 6, 17], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6, 17], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - "channels": ["on_off", "on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-8"): { - "channels": ["on_off", "on_off"], - "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, }, - "event_channels": ["8:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.router", - "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { - "device_no": 51, - "endpoints": { + DEV_SIG_DEV_NO: 51, + SIG_ENDPOINTS: { 1: { - "device_type": 262, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1024], - "out_clusters": [3], - "profile_id": 260, + SIG_EP_TYPE: 262, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1024], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", } }, - "event_channels": [], - "manufacturer": "LUMI", - "model": "lumi.sen_ill.mgl01", - "node_descriptor": b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sen_ill.mgl01", + SIG_NODE_DESC: b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 52, - "endpoints": { + DEV_SIG_DEV_NO: 52, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 18, 25, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 12, 18], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_sensor_86sw1_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_86sw1_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.sensor_86sw1", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "RemoteB186ACN01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_86sw1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "RemoteB186ACN01", }, { - "device_no": 53, - "endpoints": { + DEV_SIG_DEV_NO: 53, + SIG_ENDPOINTS: { 1: { - "device_type": 28417, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25], - "out_clusters": [0, 3, 4, 5, 18, 25], - "profile_id": 260, + SIG_EP_TYPE: 28417, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 28418, - "endpoint_id": 2, - "in_clusters": [3, 18], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 28418, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 28419, - "endpoint_id": 3, - "in_clusters": [3, 12], - "out_clusters": [3, 4, 5, 12], - "profile_id": 260, + SIG_EP_TYPE: 28419, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 12], + SIG_EP_OUTPUT: [3, 4, 5, 12], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.sensor_cube.aqgl01", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "CubeAQGL01", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_cube.aqgl01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "CubeAQGL01", }, { - "device_no": 54, - "endpoints": { + DEV_SIG_DEV_NO: 54, + SIG_ENDPOINTS: { 1: { - "device_type": 24322, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25, 1026, 1029, 65535], - "out_clusters": [0, 3, 4, 5, 18, 25, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 1026, 1029, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 24323, - "endpoint_id": 3, - "in_clusters": [3], - "out_clusters": [3, 4, 5, 12], - "profile_id": 260, + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 12], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.lumi_lumi_sensor_ht_77665544_humidity", "sensor.lumi_lumi_sensor_ht_77665544_power", "sensor.lumi_lumi_sensor_ht_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { - "channels": ["humidity"], - "entity_class": "Humidity", - "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_humidity", + DEV_SIG_CHANNELS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_humidity", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.sensor_ht", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Weather", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_ht", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Weather", }, { - "device_no": 55, - "endpoints": { + DEV_SIG_DEV_NO: 55, + SIG_ENDPOINTS: { 1: { - "device_type": 2128, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25, 65535], - "out_clusters": [0, 3, 4, 5, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 2128, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", "sensor.lumi_lumi_sensor_magnet_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_magnet_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", }, }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_magnet", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Magnet", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Magnet", }, { - "device_no": 56, - "endpoints": { + DEV_SIG_DEV_NO: 56, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 65535], - "out_clusters": [0, 4, 6, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 65535], + SIG_EP_OUTPUT: [0, 4, 6, 65535], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Opening", - "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", }, }, - "event_channels": ["1:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.sensor_magnet.aq2", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MagnetAQ2", + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MagnetAQ2", }, { - "device_no": 57, - "endpoints": { + DEV_SIG_DEV_NO: 57, + SIG_ENDPOINTS: { 1: { - "device_type": 263, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1024, 1030, 1280, 65535], - "out_clusters": [0, 25], - "profile_id": 260, + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1024, 1030, 1280, 65535], + SIG_EP_OUTPUT: [0, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): { - "channels": ["occupancy"], - "entity_class": "Occupancy", - "entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", + DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_motion.aq2", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MotionAQ2", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_motion.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MotionAQ2", }, { - "device_no": 58, - "endpoints": { + DEV_SIG_DEV_NO: 58, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 12, 18, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 12, 18, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", "sensor.lumi_lumi_sensor_smoke_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_smoke_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_smoke", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MijiaHoneywellSmokeDetectorSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_smoke", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MijiaHoneywellSmokeDetectorSensor", }, { - "device_no": 59, - "endpoints": { + DEV_SIG_DEV_NO: 59, + SIG_ENDPOINTS: { 1: { - "device_type": 6, - "endpoint_id": 1, - "in_clusters": [0, 1, 3], - "out_clusters": [0, 4, 5, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [0, 4, 5, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sensor_switch_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_switch_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_77665544_power", } }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_switch", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "MijaButton", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "MijaButton", }, { - "device_no": 60, - "endpoints": { + DEV_SIG_DEV_NO: 60, + SIG_ENDPOINTS: { 1: { - "device_type": 6, - "endpoint_id": 1, - "in_clusters": [0, 1, 65535], - "out_clusters": [0, 4, 6, 65535], - "profile_id": 260, + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 65535], + SIG_EP_OUTPUT: [0, 4, 6, 65535], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sensor_switch_aq2_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_aq2_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_switch_aq2_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_77665544_power", } }, - "event_channels": ["1:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.sensor_switch.aq2", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "SwitchAQ2", + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "SwitchAQ2", }, { - "device_no": 61, - "endpoints": { + DEV_SIG_DEV_NO: 61, + SIG_ENDPOINTS: { 1: { - "device_type": 6, - "endpoint_id": 1, - "in_clusters": [0, 1, 18], - "out_clusters": [0, 6], - "profile_id": 260, + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 18], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.lumi_lumi_sensor_switch_aq3_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_aq3_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_switch_aq3_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_77665544_power", }, }, - "event_channels": ["1:0x0006"], - "manufacturer": "LUMI", - "model": "lumi.sensor_switch.aq3", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "SwitchAQ3", + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq3", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "SwitchAQ3", }, { - "device_no": 62, - "endpoints": { + DEV_SIG_DEV_NO: 62, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1280], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "LUMI", - "model": "lumi.sensor_wleak.aq1", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "LeakAQ1", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_wleak.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "LeakAQ1", }, { - "device_no": 63, - "endpoints": { + DEV_SIG_DEV_NO: 63, + SIG_ENDPOINTS: { 1: { - "device_type": 10, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 25, 257, 1280], - "out_clusters": [0, 3, 4, 5, 25], - "profile_id": 260, + SIG_EP_TYPE: 10, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 257, 1280], + SIG_EP_OUTPUT: [0, 3, 4, 5, 25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 24322, - "endpoint_id": 2, - "in_clusters": [3], - "out_clusters": [3, 4, 5, 18], - "profile_id": 260, + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", "lock.lumi_lumi_vibration_aq1_77665544_door_lock", "sensor.lumi_lumi_vibration_aq1_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_vibration_aq1_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_77665544_power", }, ("lock", "00:11:22:33:44:55:66:77-1-257"): { - "channels": ["door_lock"], - "entity_class": "ZhaDoorLock", - "entity_id": "lock.lumi_lumi_vibration_aq1_77665544_door_lock", + DEV_SIG_CHANNELS: ["door_lock"], + DEV_SIG_ENT_MAP_CLASS: "ZhaDoorLock", + DEV_SIG_ENT_MAP_ID: "lock.lumi_lumi_vibration_aq1_77665544_door_lock", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", }, }, - "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005"], - "manufacturer": "LUMI", - "model": "lumi.vibration.aq1", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "VibrationAQ1", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.vibration.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "VibrationAQ1", }, { - "device_no": 64, - "endpoints": { + DEV_SIG_DEV_NO: 64, + SIG_ENDPOINTS: { 1: { - "device_type": 24321, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 1026, 1027, 1029, 65535], - "out_clusters": [0, 4, 65535], - "profile_id": 260, + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1026, 1027, 1029, 65535], + SIG_EP_OUTPUT: [0, 4, 65535], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.lumi_lumi_weather_77665544_humidity", "sensor.lumi_lumi_weather_77665544_power", "sensor.lumi_lumi_weather_77665544_pressure", "sensor.lumi_lumi_weather_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.lumi_lumi_weather_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.lumi_lumi_weather_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - "channels": ["pressure"], - "entity_class": "Pressure", - "entity_id": "sensor.lumi_lumi_weather_77665544_pressure", + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_pressure", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { - "channels": ["humidity"], - "entity_class": "Humidity", - "entity_id": "sensor.lumi_lumi_weather_77665544_humidity", + DEV_SIG_CHANNELS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_humidity", }, }, - "event_channels": [], - "manufacturer": "LUMI", - "model": "lumi.weather", - "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - "zha_quirks": "Weather", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.weather", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Weather", }, { - "device_no": 65, - "endpoints": { + DEV_SIG_DEV_NO: 65, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1280], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1280], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.nyce_3010_77665544_ias_zone", "sensor.nyce_3010_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.nyce_3010_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.nyce_3010_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_77665544_ias_zone", }, }, - "event_channels": [], - "manufacturer": "NYCE", - "model": "3010", - "node_descriptor": b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3010", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 66, - "endpoints": { + DEV_SIG_DEV_NO: 66, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1280], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1280], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.nyce_3014_77665544_ias_zone", "sensor.nyce_3014_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.nyce_3014_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.nyce_3014_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_77665544_ias_zone", }, }, - "event_channels": [], - "manufacturer": "NYCE", - "model": "3014", - "node_descriptor": b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3014", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 67, - "endpoints": { + DEV_SIG_DEV_NO: 67, + SIG_ENDPOINTS: { 1: { - "device_type": 5, - "endpoint_id": 1, - "in_clusters": [10, 25], - "out_clusters": [1280], - "profile_id": 260, + SIG_EP_TYPE: 5, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [10, 25], + SIG_EP_OUTPUT: [1280], + SIG_EP_PROFILE: 260, }, 242: { - "device_type": 100, - "endpoint_id": 242, - "in_clusters": [], - "out_clusters": [33], - "profile_id": 41440, + SIG_EP_TYPE: 100, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, }, }, - "entities": ["1:0x0019"], - "entity_map": {}, - "event_channels": [], - "manufacturer": None, - "model": None, - "node_descriptor": b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", + DEV_SIG_ENTITIES: ["1:0x0019"], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", }, { - "device_no": 68, - "endpoints": { + DEV_SIG_DEV_NO: 68, + SIG_ENDPOINTS: { 1: { - "device_type": 48879, - "endpoint_id": 1, - "in_clusters": [], - "out_clusters": [1280], - "profile_id": 260, + SIG_EP_TYPE: 48879, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [1280], + SIG_EP_PROFILE: 260, } }, - "entities": [], - "entity_map": {}, - "event_channels": [], - "manufacturer": None, - "model": None, - "node_descriptor": b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", + DEV_SIG_ENTITIES: [], + DEV_SIG_ENT_MAP: {}, + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", }, { - "device_no": 69, - "endpoints": { + DEV_SIG_DEV_NO: 69, + SIG_ENDPOINTS: { 3: { - "device_type": 258, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 64527], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": ["light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off" + ], + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY A19 RGBW", - "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - "zha_quirks": "LIGHTIFYA19RGBW", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_ZHA_QUIRK: "LIGHTIFYA19RGBW", }, { - "device_no": 70, - "endpoints": { + DEV_SIG_DEV_NO: 70, + SIG_ENDPOINTS: { 1: { - "device_type": 1, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 2821], - "out_clusters": [3, 6, 8, 25], - "profile_id": 260, + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 2821], + SIG_EP_OUTPUT: [3, 6, 8, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["sensor.osram_lightify_dimming_switch_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.osram_lightify_dimming_switch_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.osram_lightify_dimming_switch_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY Dimming Switch", - "node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "CentraLite3130", + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Dimming Switch", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "CentraLite3130", }, { - "device_no": 71, - "endpoints": { + DEV_SIG_DEV_NO: 71, + SIG_ENDPOINTS: { 3: { - "device_type": 258, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 64527], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off" ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off", } }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY Flex RGBW", - "node_descriptor": b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - "zha_quirks": "FlexRGBW", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Flex RGBW", + SIG_NODE_DESC: b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_ZHA_QUIRK: "FlexRGBW", }, { - "device_no": 72, - "endpoints": { + DEV_SIG_DEV_NO: 72, + SIG_ENDPOINTS: { 3: { - "device_type": 258, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2820, 64527], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2820, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", }, }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "LIGHTIFY RT Tunable White", - "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - "zha_quirks": "A19TunableWhite", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY RT Tunable White", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_ZHA_QUIRK: "A19TunableWhite", }, { - "device_no": 73, - "endpoints": { + DEV_SIG_DEV_NO: 73, + SIG_ENDPOINTS: { 3: { - "device_type": 16, - "endpoint_id": 3, - "in_clusters": [0, 3, 4, 5, 6, 2820, 4096, 64527], - "out_clusters": [25], - "profile_id": 49246, + SIG_EP_TYPE: 16, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 4096, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 49246, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.osram_plug_01_77665544_electrical_measurement", "switch.osram_plug_01_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-3"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.osram_plug_01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.osram_plug_01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.osram_plug_01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_77665544_electrical_measurement", }, }, - "event_channels": ["3:0x0019"], - "manufacturer": "OSRAM", - "model": "Plug 01", - "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Plug 01", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", }, { - "device_no": 74, - "endpoints": { + DEV_SIG_DEV_NO: 74, + SIG_ENDPOINTS: { 1: { - "device_type": 2064, - "endpoint_id": 1, - "in_clusters": [0, 1, 32, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 25, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 32, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 768, 4096], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 2064, - "endpoint_id": 2, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 2064, - "endpoint_id": 3, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 4: { - "device_type": 2064, - "endpoint_id": 4, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 5: { - "device_type": 2064, - "endpoint_id": 5, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, 6: { - "device_type": 2064, - "endpoint_id": 6, - "in_clusters": [0, 4096, 64768], - "out_clusters": [3, 4, 5, 6, 8, 768, 4096], - "profile_id": 260, + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.osram_switch_4x_lightify_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.osram_switch_4x_lightify_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.osram_switch_4x_lightify_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_77665544_power", } }, - "event_channels": [ + DEV_SIG_EVT_CHANNELS: [ "1:0x0005", "1:0x0006", "1:0x0008", @@ -2630,969 +2698,1004 @@ DEVICES = [ "6:0x0008", "6:0x0300", ], - "manufacturer": "OSRAM", - "model": "Switch 4x-LIGHTIFY", - "node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "LightifyX4", + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Switch 4x-LIGHTIFY", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "LightifyX4", }, { - "device_no": 75, - "endpoints": { + DEV_SIG_DEV_NO: 75, + SIG_ENDPOINTS: { 1: { - "device_type": 2096, - "endpoint_id": 1, - "in_clusters": [0], - "out_clusters": [0, 3, 4, 5, 6, 8], - "profile_id": 49246, + SIG_EP_TYPE: 2096, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0], + SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8], + SIG_EP_PROFILE: 49246, }, 2: { - "device_type": 12, - "endpoint_id": 2, - "in_clusters": [0, 1, 3, 15, 64512], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 1, 3, 15, 64512], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, }, - "entities": ["sensor.philips_rwl020_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["sensor.philips_rwl020_77665544_power"], + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-2-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.philips_rwl020_77665544_power", - } + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_77665544_power", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-15"): { + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_rwl020_77665544_binary_input", + }, }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], - "manufacturer": "Philips", - "model": "RWL020", - "node_descriptor": b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", - "zha_quirks": "PhilipsRWL021", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "RWL020", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", + DEV_SIG_ZHA_QUIRK: "PhilipsRWL021", }, { - "device_no": 76, - "endpoints": { + DEV_SIG_DEV_NO: 76, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.samjin_button_77665544_ias_zone", "sensor.samjin_button_77665544_power", "sensor.samjin_button_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.samjin_button_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.samjin_button_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.samjin_button_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Samjin", - "model": "button", - "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", - "zha_quirks": "SamjinButton", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "button", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "SamjinButton", }, { - "device_no": 77, - "endpoints": { + DEV_SIG_DEV_NO: 77, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 64514], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 64514], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.samjin_multi_77665544_ias_zone", "binary_sensor.samjin_multi_77665544_manufacturer_specific", "sensor.samjin_multi_77665544_power", "sensor.samjin_multi_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.samjin_multi_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.samjin_multi_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.samjin_multi_77665544_ias_zone", - }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - "channels": ["manufacturer_specific"], - "entity_class": "BinarySensor", - "entity_id": "binary_sensor.samjin_multi_77665544_manufacturer_specific", - "default_match": True, + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Samjin", - "model": "multi", - "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", - "zha_quirks": "SmartthingsMultiPurposeSensor", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "multi", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + DEV_SIG_ZHA_QUIRK: "SmartthingsMultiPurposeSensor", }, { - "device_no": 78, - "endpoints": { + DEV_SIG_DEV_NO: 78, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.samjin_water_77665544_ias_zone", "sensor.samjin_water_77665544_power", "sensor.samjin_water_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.samjin_water_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.samjin_water_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.samjin_water_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Samjin", - "model": "water", - "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "water", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", }, { - "device_no": 79, - "endpoints": { + DEV_SIG_DEV_NO: 79, + SIG_ENDPOINTS: { 1: { - "device_type": 0, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 6, 2820, 2821], - "out_clusters": [0, 1, 3, 4, 5, 6, 25, 2820, 2821], - "profile_id": 260, + SIG_EP_TYPE: 0, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 2820, 2821], + SIG_EP_OUTPUT: [0, 1, 3, 4, 5, 6, 25, 2820, 2821], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", "switch.securifi_ltd_unk_model_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.securifi_ltd_unk_model_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0005", "1:0x0006", "1:0x0019"], - "manufacturer": "Securifi Ltd.", - "model": None, - "node_descriptor": b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], + SIG_MANUFACTURER: "Securifi Ltd.", + SIG_MODEL: None, + SIG_NODE_DESC: b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", }, { - "device_no": 80, - "endpoints": { + DEV_SIG_DEV_NO: 80, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sercomm Corp.", - "model": "SZ-DWS04N_SF", - "node_descriptor": b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-DWS04N_SF", + SIG_NODE_DESC: b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", }, { - "device_no": 81, - "endpoints": { + DEV_SIG_DEV_NO: 81, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 6, 1794, 2820, 2821], - "out_clusters": [3, 10, 25, 2821], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 1794, 2820, 2821], + SIG_EP_OUTPUT: [3, 10, 25, 2821], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 259, - "endpoint_id": 2, - "in_clusters": [0, 1, 3], - "out_clusters": [3, 6], - "profile_id": 260, + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sercomm_corp_sz_esw01_77665544_on_off", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.sercomm_corp_sz_esw01_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sercomm_corp_sz_esw01_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019", "2:0x0006"], - "manufacturer": "Sercomm Corp.", - "model": "SZ-ESW01", - "node_descriptor": b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-ESW01", + SIG_NODE_DESC: b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 82, - "endpoints": { + DEV_SIG_DEV_NO: 82, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1024, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", "sensor.sercomm_corp_sz_pir04_77665544_illuminance", "sensor.sercomm_corp_sz_pir04_77665544_power", "sensor.sercomm_corp_sz_pir04_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - "channels": ["illuminance"], - "entity_class": "Illuminance", - "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_illuminance", + DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sercomm Corp.", - "model": "SZ-PIR04", - "node_descriptor": b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-PIR04", + SIG_NODE_DESC: b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 83, - "endpoints": { + DEV_SIG_DEV_NO: 83, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 2820, 2821, 65281], - "out_clusters": [3, 4, 25], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 2821, 65281], + SIG_EP_OUTPUT: [3, 4, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", "switch.sinope_technologies_rm3250zb_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.sinope_technologies_rm3250zb_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sinope Technologies", - "model": "RM3250ZB", - "node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "RM3250ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", }, { - "device_no": 84, - "endpoints": { + DEV_SIG_DEV_NO: 84, + SIG_ENDPOINTS: { 1: { - "device_type": 769, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], - "out_clusters": [25, 65281], - "profile_id": 260, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], + SIG_EP_OUTPUT: [25, 65281], + SIG_EP_PROFILE: 260, }, 196: { - "device_type": 769, - "endpoint_id": 196, - "in_clusters": [1], - "out_clusters": [], - "profile_id": 49757, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 196, + SIG_EP_INPUT: [1], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49757, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "climate.sinope_technologies_th1123zb_77665544_thermostat", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1123zb_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("climate", "00:11:22:33:44:55:66:77-1"): { - "channels": ["thermostat"], - "entity_class": "Thermostat", - "entity_id": "climate.sinope_technologies_th1123zb_77665544_thermostat", + DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "Thermostat", + DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1123zb_77665544_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sinope_technologies_th1123zb_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sinope Technologies", - "model": "TH1123ZB", - "node_descriptor": b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", - "zha_quirks": "SinopeTechnologiesThermostat", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1123ZB", + SIG_NODE_DESC: b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", + DEV_SIG_ZHA_QUIRK: "SinopeTechnologiesThermostat", }, { - "device_no": 85, - "endpoints": { + DEV_SIG_DEV_NO: 85, + SIG_ENDPOINTS: { 1: { - "device_type": 769, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], - "out_clusters": [25, 65281], - "profile_id": 260, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], + SIG_EP_OUTPUT: [25, 65281], + SIG_EP_PROFILE: 260, }, 196: { - "device_type": 769, - "endpoint_id": 196, - "in_clusters": [1], - "out_clusters": [], - "profile_id": 49757, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 196, + SIG_EP_INPUT: [1], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49757, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1124zb_77665544_temperature", "climate.sinope_technologies_th1124zb_77665544_thermostat", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("climate", "00:11:22:33:44:55:66:77-1"): { - "channels": ["thermostat"], - "entity_class": "Thermostat", - "entity_id": "climate.sinope_technologies_th1124zb_77665544_thermostat", + DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "Thermostat", + DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_77665544_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.sinope_technologies_th1124zb_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Sinope Technologies", - "model": "TH1124ZB", - "node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", - "zha_quirks": "SinopeTechnologiesThermostat", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1124ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", + DEV_SIG_ZHA_QUIRK: "SinopeTechnologiesThermostat", }, { - "device_no": 86, - "endpoints": { + DEV_SIG_DEV_NO: 86, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 9, 15, 2820], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 9, 15, 2820], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.smartthings_outletv4_77665544_electrical_measurement", "switch.smartthings_outletv4_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.smartthings_outletv4_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_77665544_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - "channels": ["electrical_measurement"], - "entity_class": "ElectricalMeasurement", - "entity_id": "sensor.smartthings_outletv4_77665544_electrical_measurement", + DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_77665544_electrical_measurement", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_77665544_binary_input", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "SmartThings", - "model": "outletv4", - "node_descriptor": b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "outletv4", + SIG_NODE_DESC: b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 87, - "endpoints": { + DEV_SIG_DEV_NO: 87, + SIG_ENDPOINTS: { 1: { - "device_type": 32768, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 15, 32], - "out_clusters": [3, 25], - "profile_id": 260, + SIG_EP_TYPE: 32768, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 15, 32], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, } }, - "entities": ["device_tracker.smartthings_tagv4_77665544_power"], - "entity_map": { + DEV_SIG_ENTITIES: ["device_tracker.smartthings_tagv4_77665544_power"], + DEV_SIG_ENT_MAP: { ("device_tracker", "00:11:22:33:44:55:66:77-1"): { - "channels": ["power"], - "entity_class": "ZHADeviceScannerEntity", - "entity_id": "device_tracker.smartthings_tagv4_77665544_power", - } + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "ZHADeviceScannerEntity", + DEV_SIG_ENT_MAP_ID: "device_tracker.smartthings_tagv4_77665544_power", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_tagv4_77665544_binary_input", + }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "SmartThings", - "model": "tagv4", - "node_descriptor": b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", - "zha_quirks": "SmartThingsTagV4", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "tagv4", + SIG_NODE_DESC: b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "SmartThingsTagV4", }, { - "device_no": 88, - "endpoints": { + DEV_SIG_DEV_NO: 88, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 25], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 25], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": ["switch.third_reality_inc_3rss007z_77665544_on_off"], - "entity_map": { + DEV_SIG_ENTITIES: ["switch.third_reality_inc_3rss007z_77665544_on_off"], + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.third_reality_inc_3rss007z_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_77665544_on_off", } }, - "event_channels": [], - "manufacturer": "Third Reality, Inc", - "model": "3RSS007Z", - "node_descriptor": b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS007Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", }, { - "device_no": 89, - "endpoints": { + DEV_SIG_DEV_NO: 89, + SIG_ENDPOINTS: { 1: { - "device_type": 2, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 6, 25], - "out_clusters": [1], - "profile_id": 260, + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 25], + SIG_EP_OUTPUT: [1], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "sensor.third_reality_inc_3rss008z_77665544_power", "switch.third_reality_inc_3rss008z_77665544_on_off", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.third_reality_inc_3rss008z_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_77665544_power", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.third_reality_inc_3rss008z_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_77665544_on_off", }, }, - "event_channels": [], - "manufacturer": "Third Reality, Inc", - "model": "3RSS008Z", - "node_descriptor": b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", - "zha_quirks": "Switch", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS008Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ZHA_QUIRK: "Switch", }, { - "device_no": 90, - "endpoints": { + DEV_SIG_DEV_NO: 90, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 32, 1026, 1280, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.visonic_mct_340_e_77665544_ias_zone", "sensor.visonic_mct_340_e_77665544_power", "sensor.visonic_mct_340_e_77665544_temperature", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.visonic_mct_340_e_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - "channels": ["temperature"], - "entity_class": "Temperature", - "entity_id": "sensor.visonic_mct_340_e_77665544_temperature", + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_temperature", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.visonic_mct_340_e_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_77665544_ias_zone", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Visonic", - "model": "MCT-340 E", - "node_descriptor": b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "MCT340E", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Visonic", + SIG_MODEL: "MCT-340 E", + SIG_NODE_DESC: b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "MCT340E", }, { - "device_no": 91, - "endpoints": { + DEV_SIG_DEV_NO: 91, + SIG_ENDPOINTS: { 1: { - "device_type": 769, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821], - "out_clusters": [10, 25], - "profile_id": 260, + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "climate.zen_within_zen_01_77665544_fan_thermostat", "sensor.zen_within_zen_01_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.zen_within_zen_01_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_power", }, ("climate", "00:11:22:33:44:55:66:77-1"): { - "channels": ["thermostat", "fan"], - "entity_class": "ZenWithinThermostat", - "entity_id": "climate.zen_within_zen_01_77665544_fan_thermostat", + DEV_SIG_CHANNELS: ["thermostat", "fan"], + DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat", + DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_77665544_fan_thermostat", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "Zen Within", - "model": "Zen-01", - "node_descriptor": b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "Zen Within", + SIG_MODEL: "Zen-01", + SIG_NODE_DESC: b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", }, { - "device_no": 92, - "endpoints": { + DEV_SIG_DEV_NO: 92, + SIG_ENDPOINTS: { 1: { - "device_type": 256, - "endpoint_id": 1, - "in_clusters": [0, 4, 5, 6, 10], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 4, 5, 6, 10], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, }, 2: { - "device_type": 256, - "endpoint_id": 2, - "in_clusters": [4, 5, 6], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, 3: { - "device_type": 256, - "endpoint_id": 3, - "in_clusters": [4, 5, 6], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, 4: { - "device_type": 256, - "endpoint_id": 4, - "in_clusters": [4, 5, 6], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", }, ("light", "00:11:22:33:44:55:66:77-2"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", }, ("light", "00:11:22:33:44:55:66:77-3"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", }, ("light", "00:11:22:33:44:55:66:77-4"): { - "channels": ["on_off"], - "entity_class": "Light", - "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "_TYZB01_ns1ndbww", - "model": "TS0004", - "node_descriptor": b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "_TYZB01_ns1ndbww", + SIG_MODEL: "TS0004", + SIG_NODE_DESC: b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", }, { - "device_no": 93, - "endpoints": { + DEV_SIG_DEV_NO: 93, + SIG_ENDPOINTS: { 1: { - "device_type": 1026, - "endpoint_id": 1, - "in_clusters": [0, 1, 3, 21, 32, 1280, 2821], - "out_clusters": [], - "profile_id": 260, + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 21, 32, 1280, 2821], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "binary_sensor.netvox_z308e3ed_77665544_ias_zone", "sensor.netvox_z308e3ed_77665544_power", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - "channels": ["power"], - "entity_class": "Battery", - "entity_id": "sensor.netvox_z308e3ed_77665544_power", + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_77665544_power", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - "channels": ["ias_zone"], - "entity_class": "IASZone", - "entity_id": "binary_sensor.netvox_z308e3ed_77665544_ias_zone", + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_77665544_ias_zone", }, }, - "event_channels": [], - "manufacturer": "netvox", - "model": "Z308E3ED", - "node_descriptor": b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", - "zha_quirks": "Z308E3ED", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "netvox", + SIG_MODEL: "Z308E3ED", + SIG_NODE_DESC: b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", + DEV_SIG_ZHA_QUIRK: "Z308E3ED", }, { - "device_no": 94, - "endpoints": { + DEV_SIG_DEV_NO: 94, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sengled_e11_g13_77665544_level_on_off", "sensor.sengled_e11_g13_77665544_smartenergy_metering", + "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.sengled_e11_g13_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sengled_e11_g13_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sengled_e11_g13_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "sengled", - "model": "E11-G13", - "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E11-G13", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 95, - "endpoints": { + DEV_SIG_DEV_NO: 95, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sengled_e12_n14_77665544_level_on_off", "sensor.sengled_e12_n14_77665544_smartenergy_metering", + "sensor.sengled_e12_n14_77665544_smartenergy_metering_sumaiton_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off"], - "entity_class": "Light", - "entity_id": "light.sengled_e12_n14_77665544_level_on_off", + DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sengled_e12_n14_77665544_level_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sengled_e12_n14_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "sengled", - "model": "E12-N14", - "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E12-N14", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 96, - "endpoints": { + DEV_SIG_DEV_NO: 96, + SIG_ENDPOINTS: { 1: { - "device_type": 257, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 768, 1794, 2821], - "out_clusters": [25], - "profile_id": 260, + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, } }, - "entities": [ + DEV_SIG_ENTITIES: [ "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", + "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "light_color", "on_off"], - "entity_class": "Light", - "entity_id": "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", + DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - "channels": ["smartenergy_metering"], - "entity_class": "SmartEnergyMetering", - "entity_id": "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", }, }, - "event_channels": ["1:0x0019"], - "manufacturer": "sengled", - "model": "Z01-A19NAE26", - "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "Z01-A19NAE26", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 97, - "endpoints": { + DEV_SIG_DEV_NO: 97, + SIG_ENDPOINTS: { 1: { - "device_type": 512, - "endpoint_id": 1, - "in_clusters": [0, 3, 4, 5, 6, 8, 10, 21, 256, 64544, 64545], - "out_clusters": [3, 64544], - "profile_id": 260, + SIG_EP_TYPE: 512, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 10, 21, 256, 64544, 64545], + SIG_EP_OUTPUT: [3, 64544], + SIG_EP_PROFILE: 260, } }, - "entities": ["cover.unk_manufacturer_unk_model_77665544_level_on_off_shade"], - "entity_map": { + DEV_SIG_ENTITIES: [ + "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade" + ], + DEV_SIG_ENT_MAP: { ("cover", "00:11:22:33:44:55:66:77-1"): { - "channels": ["level", "on_off", "shade"], - "entity_class": "Shade", - "entity_id": "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade", + DEV_SIG_CHANNELS: ["level", "on_off", "shade"], + DEV_SIG_ENT_MAP_CLASS: "Shade", + DEV_SIG_ENT_MAP_ID: "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade", } }, - "event_channels": [], - "manufacturer": "unk_manufacturer", - "model": "unk_model", - "node_descriptor": b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "unk_manufacturer", + SIG_MODEL: "unk_model", + SIG_NODE_DESC: b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", }, { - "device_no": 98, - "endpoints": { + DEV_SIG_DEV_NO: 98, + SIG_ENDPOINTS: { 208: { - "endpoint_id": 208, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 208, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 209: { - "endpoint_id": 209, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 209, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 210: { - "endpoint_id": 210, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 210, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 211: { - "endpoint_id": 211, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 211, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 212: { - "endpoint_id": 212, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 212, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 213: { - "endpoint_id": 213, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 213, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 214: { - "endpoint_id": 214, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 214, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 215: { - "endpoint_id": 215, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000C], - "out_clusters": [], + DEV_SIG_EP_ID: 215, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_OUTPUT: [], }, 216: { - "endpoint_id": 216, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 216, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 217: { - "endpoint_id": 217, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 217, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 218: { - "endpoint_id": 218, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000D], - "out_clusters": [], + DEV_SIG_EP_ID: 218, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000D], + SIG_EP_OUTPUT: [], }, 219: { - "endpoint_id": 219, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006, 0x000D], - "out_clusters": [], + DEV_SIG_EP_ID: 219, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006, 0x000D], + SIG_EP_OUTPUT: [], }, 220: { - "endpoint_id": 220, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 220, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 221: { - "endpoint_id": 221, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 221, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 222: { - "endpoint_id": 222, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0006], - "out_clusters": [], + DEV_SIG_EP_ID: 222, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0006], + SIG_EP_OUTPUT: [], }, 232: { - "endpoint_id": 232, - "profile_id": 49413, - "device_type": 0x0001, - "in_clusters": [0x0011, 0x0092], - "out_clusters": [0x0008, 0x0011], + DEV_SIG_EP_ID: 232, + SIG_EP_PROFILE: 49413, + SIG_EP_TYPE: 0x0001, + SIG_EP_INPUT: [0x0011, 0x0092], + SIG_EP_OUTPUT: [0x0008, 0x0011], }, }, - "entities": [ + DEV_SIG_ENTITIES: [ "switch.digi_xbee3_77665544_on_off", "switch.digi_xbee3_77665544_on_off_2", "switch.digi_xbee3_77665544_on_off_3", @@ -3615,121 +3718,121 @@ DEVICES = [ "number.digi_xbee3_77665544_analog_output", "number.digi_xbee3_77665544_analog_output_2", ], - "entity_map": { + DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-208-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off", }, ("switch", "00:11:22:33:44:55:66:77-209-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_2", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_2", }, ("switch", "00:11:22:33:44:55:66:77-210-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_3", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_3", }, ("switch", "00:11:22:33:44:55:66:77-211-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_4", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_4", }, ("switch", "00:11:22:33:44:55:66:77-212-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_5", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_5", }, ("switch", "00:11:22:33:44:55:66:77-213-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_6", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_6", }, ("switch", "00:11:22:33:44:55:66:77-214-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_7", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_7", }, ("switch", "00:11:22:33:44:55:66:77-215-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_8", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_8", }, ("switch", "00:11:22:33:44:55:66:77-216-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_9", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_9", }, ("switch", "00:11:22:33:44:55:66:77-217-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_10", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_10", }, ("switch", "00:11:22:33:44:55:66:77-218-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_11", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_11", }, ("switch", "00:11:22:33:44:55:66:77-219-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_12", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_12", }, ("switch", "00:11:22:33:44:55:66:77-220-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_13", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_13", }, ("switch", "00:11:22:33:44:55:66:77-221-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_14", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_14", }, ("switch", "00:11:22:33:44:55:66:77-222-6"): { - "channels": ["on_off"], - "entity_class": "Switch", - "entity_id": "switch.digi_xbee3_77665544_on_off_15", + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_15", }, ("sensor", "00:11:22:33:44:55:66:77-208-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input", }, ("sensor", "00:11:22:33:44:55:66:77-209-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_2", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_2", }, ("sensor", "00:11:22:33:44:55:66:77-210-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_3", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_3", }, ("sensor", "00:11:22:33:44:55:66:77-211-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_4", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_4", }, ("sensor", "00:11:22:33:44:55:66:77-215-12"): { - "channels": ["analog_input"], - "entity_class": "AnalogInput", - "entity_id": "sensor.digi_xbee3_77665544_analog_input_5", + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_5", }, ("number", "00:11:22:33:44:55:66:77-218-13"): { - "channels": ["analog_output"], - "entity_class": "ZhaNumber", - "entity_id": "number.digi_xbee3_77665544_analog_output", + DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output", }, ("number", "00:11:22:33:44:55:66:77-219-13"): { - "channels": ["analog_output"], - "entity_class": "ZhaNumber", - "entity_id": "number.digi_xbee3_77665544_analog_output_2", + DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output_2", }, }, - "event_channels": ["232:0x0008"], - "manufacturer": "Digi", - "model": "XBee3", - "node_descriptor": b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", + DEV_SIG_EVT_CHANNELS: ["232:0x0008"], + SIG_MANUFACTURER: "Digi", + SIG_MODEL: "XBee3", + SIG_NODE_DESC: b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", }, ] diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index ecf5759b835..b0114d087ad 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -15,8 +15,7 @@ from homeassistant.components.zwave import ( DATA_NETWORK, const, ) -from homeassistant.components.zwave.binary_sensor import get_device -from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME +from homeassistant.const import ATTR_NAME from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -1854,38 +1853,6 @@ async def test_remove_association(hass, mock_openzwave, zwave_setup_ready): assert group.remove_association.mock_calls[0][1][1] == 5 -async def test_refresh_entity(hass, mock_openzwave, zwave_setup_ready): - """Test zwave refresh_entity service.""" - node = MockNode() - value = MockValue( - data=False, node=node, command_class=const.COMMAND_CLASS_SENSOR_BINARY - ) - power_value = MockValue(data=50, node=node, command_class=const.COMMAND_CLASS_METER) - values = MockEntityValues(primary=value, power=power_value) - device = get_device(node=node, values=values, node_config={}) - device.hass = hass - device.entity_id = "binary_sensor.mock_entity_id" - await device.async_added_to_hass() - await hass.async_block_till_done() - - await hass.services.async_call( - "zwave", "refresh_entity", {ATTR_ENTITY_ID: "binary_sensor.mock_entity_id"} - ) - await hass.async_block_till_done() - - assert node.refresh_value.called - assert len(node.refresh_value.mock_calls) == 2 - assert ( - sorted( - [ - node.refresh_value.mock_calls[0][1][0], - node.refresh_value.mock_calls[1][1][0], - ] - ) - == sorted([value.value_id, power_value.value_id]) - ) - - async def test_refresh_node(hass, mock_openzwave, zwave_setup_ready): """Test zwave refresh_node service.""" zwave_network = hass.data[DATA_NETWORK] diff --git a/tests/components/zwave/test_websocket_api.py b/tests/components/zwave/test_websocket_api.py index 2e37ed47fce..2ad94d29b0e 100644 --- a/tests/components/zwave/test_websocket_api.py +++ b/tests/components/zwave/test_websocket_api.py @@ -44,8 +44,8 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client): assert result[CONF_POLLING_INTERVAL] == 6000 -async def test_zwave_ozw_migration_api(hass, mock_openzwave, hass_ws_client): - """Test Z-Wave to OpenZWave websocket migration API.""" +async def test_zwave_zwave_js_migration_api(hass, mock_openzwave, hass_ws_client): + """Test Z-Wave to Z-Wave JS websocket migration API.""" await async_setup_component( hass, @@ -76,14 +76,14 @@ async def test_zwave_ozw_migration_api(hass, mock_openzwave, hass_ws_client): ) as async_init: async_init.return_value = {"flow_id": "mock_flow_id"} - await client.send_json({ID: 7, TYPE: "zwave/start_ozw_config_flow"}) + await client.send_json({ID: 7, TYPE: "zwave/start_zwave_js_config_flow"}) msg = await client.receive_json() result = msg["result"] assert result["flow_id"] == "mock_flow_id" assert async_init.call_args == call( - "ozw", + "zwave_js", context={"source": config_entries.SOURCE_IMPORT}, data={"usb_path": "/dev/zwave", "network_key": NETWORK_KEY}, ) diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 2590149c462..e8e3151134c 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -33,3 +33,5 @@ ID_LOCK_CONFIG_PARAMETER_SENSOR = ( 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" + +PROPERTY_ULTRAVIOLET = "Ultraviolet" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 6634fdf759d..422f4b55c16 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -455,6 +455,12 @@ def lock_popp_electric_strike_lock_control_state_fixture(): ) +@pytest.fixture(name="fortrezz_ssa1_siren_state", scope="session") +def fortrezz_ssa1_siren_state_fixture(): + """Load the fortrezz ssa1 siren node state fixture data.""" + return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -859,6 +865,14 @@ def lock_popp_electric_strike_lock_control_fixture( return node +@pytest.fixture(name="fortrezz_ssa1_siren") +def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): + """Mock a fortrezz ssa1 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa1_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.""" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b3bb924413d..44b9acf2db5 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1,9 +1,15 @@ """Test the Z-Wave JS Websocket API.""" +from copy import deepcopy import json from unittest.mock import patch import pytest -from zwave_js_server.const import InclusionStrategy, LogLevel +from zwave_js_server.const import ( + CommandClass, + InclusionStrategy, + LogLevel, + SecurityClass, +) from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, @@ -12,9 +18,12 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) +from zwave_js_server.model.node import Node +from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( + CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, CONFIG, ENABLED, @@ -23,13 +32,15 @@ from homeassistant.components.zwave_js.api import ( FILENAME, FORCE_CONSOLE, ID, + INCLUSION_STRATEGY, LEVEL, LOG_TO_FILE, NODE_ID, OPTED_IN, + PIN, PROPERTY, PROPERTY_KEY, - SECURE, + SECURITY_CLASSES, TYPE, VALUE, ) @@ -39,6 +50,8 @@ from homeassistant.components.zwave_js.const import ( ) from homeassistant.helpers import device_registry as dr +from .common import PROPERTY_ULTRAVIOLET + async def test_network_status(hass, integration, hass_ws_client): """Test the network status websocket command.""" @@ -67,6 +80,51 @@ async def test_network_status(hass, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_node_ready( + hass, + multisensor_6_state, + client, + integration, + hass_ws_client, +): + """Test the node ready websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. + node = Node(client, node_data) + node.data["ready"] = False + client.driver.controller.nodes[node.node_id] = node + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/node_ready", + ENTRY_ID: entry.entry_id, + "node_id": node.node_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + node.data["ready"] = True + event = Event( + "ready", + { + "source": "node", + "event": "ready", + "nodeId": node.node_id, + "nodeState": node.data, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + msg = await ws_client.receive_json() + + assert msg["event"]["event"] == "ready" + + async def test_node_status(hass, multisensor_6, integration, hass_ws_client): """Test the node status websocket command.""" entry = integration @@ -127,6 +185,28 @@ async def test_node_state(hass, multisensor_6, integration, hass_ws_client): ws_client = await hass_ws_client(hass) node = multisensor_6 + + # Update a value and ensure it is reflected in the node state + value_id = get_value_id(node, CommandClass.SENSOR_MULTILEVEL, PROPERTY_ULTRAVIOLET) + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": PROPERTY_ULTRAVIOLET, + "newValue": 1, + "prevValue": 0, + "propertyName": PROPERTY_ULTRAVIOLET, + }, + }, + ) + node.receive_event(event) + await ws_client.send_json( { ID: 3, @@ -136,7 +216,17 @@ async def test_node_state(hass, multisensor_6, integration, hass_ws_client): } ) msg = await ws_client.receive_json() - assert msg["result"] == node.data + + # Assert that the data returned doesn't match the stale node state data + assert msg["result"] != node.data + + # Replace data for the value we updated and assert the new node data is the same + # as what's returned + updated_node_data = node.data.copy() + for n, value in enumerate(updated_node_data["values"]): + if _get_value_id_from_dict(node, value) == value_id: + updated_node_data["values"][n] = node.values[value_id].data.copy() + assert msg["result"] == updated_node_data # Test getting non-existent node fails await ws_client.send_json( @@ -319,31 +409,6 @@ async def test_ping_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_add_node_secure( - hass, nortek_thermostat_added_event, integration, client, hass_ws_client -): - """Test the add_node websocket command with secure flag.""" - entry = integration - ws_client = await hass_ws_client(hass) - - client.async_send_command.return_value = {"success": True} - - await ws_client.send_json( - {ID: 1, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, SECURE: True} - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.begin_inclusion", - "options": {"strategy": InclusionStrategy.SECURITY_S0}, - } - - client.async_send_command.reset_mock() - - async def test_add_node( hass, nortek_thermostat_added_event, integration, client, hass_ws_client ): @@ -354,7 +419,12 @@ async def test_add_node( client.async_send_command.return_value = {"success": True} await ws_client.send_json( - {ID: 3, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + { + ID: 3, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, + } ) msg = await ws_client.receive_json() @@ -363,7 +433,7 @@ async def test_add_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"strategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.DEFAULT}, } event = Event( @@ -379,6 +449,37 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": {"securityClasses": [0, 1, 2, 7], "clientSideAuth": False}, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "grant security classes" + assert msg["event"]["requested_grant"] == { + "securityClasses": [0, 1, 2, 7], + "clientSideAuth": False, + } + + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "validate dsk and enter pin" + assert msg["event"]["dsk"] == "test" + client.driver.receive_event(nortek_thermostat_added_event) msg = await ws_client.receive_json() assert msg["event"]["event"] == "node added" @@ -386,6 +487,7 @@ async def test_add_node( "node_id": 67, "status": 0, "ready": False, + "low_security": False, } assert msg["event"]["node"] == node_details @@ -468,6 +570,94 @@ async def test_add_node( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_grant_security_classes(hass, integration, client, hass_ws_client): + """Test the grant_security_classes websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/grant_security_classes", + ENTRY_ID: entry.entry_id, + SECURITY_CLASSES: [SecurityClass.S2_UNAUTHENTICATED], + CLIENT_SIDE_AUTH: False, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.grant_security_classes", + "inclusionGrant": {"securityClasses": [0], "clientSideAuth": False}, + } + + # 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/grant_security_classes", + ENTRY_ID: entry.entry_id, + SECURITY_CLASSES: [SecurityClass.S2_UNAUTHENTICATED], + CLIENT_SIDE_AUTH: False, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_validate_dsk_and_enter_pin(hass, integration, client, hass_ws_client): + """Test the validate_dsk_and_enter_pin websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/validate_dsk_and_enter_pin", + ENTRY_ID: entry.entry_id, + PIN: "test", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.validate_dsk_and_enter_pin", + "pin": "test", + } + + # 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/validate_dsk_and_enter_pin", + ENTRY_ID: entry.entry_id, + PIN: "test", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_client): """Test cancelling the inclusion and exclusion process.""" entry = integration @@ -572,7 +762,6 @@ async def test_remove_node( data={ "source": "controller", "event": "exclusion started", - "secure": False, }, ) client.driver.receive_event(event) @@ -631,52 +820,6 @@ async def test_remove_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_replace_failed_node_secure( - hass, - nortek_thermostat, - integration, - client, - hass_ws_client, -): - """Test the replace_failed_node websocket command with secure flag.""" - entry = integration - ws_client = await hass_ws_client(hass) - - dev_reg = dr.async_get(hass) - - # Create device registry entry for mock node - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, "3245146787-67")}, - name="Node 67", - ) - - client.async_send_command.return_value = {"success": True} - - await ws_client.send_json( - { - ID: 1, - TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, - SECURE: True, - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - assert msg["result"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.replace_failed_node", - "nodeId": nortek_thermostat.node_id, - "options": {"strategy": InclusionStrategy.SECURITY_S0}, - } - - client.async_send_command.reset_mock() - - async def test_replace_failed_node( hass, nortek_thermostat, @@ -709,6 +852,7 @@ async def test_replace_failed_node( TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, } ) @@ -720,7 +864,7 @@ async def test_replace_failed_node( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"strategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.DEFAULT}, } client.async_send_command.reset_mock() @@ -738,12 +882,42 @@ async def test_replace_failed_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": {"securityClasses": [0, 1, 2, 7], "clientSideAuth": False}, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "grant security classes" + assert msg["event"]["requested_grant"] == { + "securityClasses": [0, 1, 2, 7], + "clientSideAuth": False, + } + + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "validate dsk and enter pin" + assert msg["event"]["dsk"] == "test" + event = Event( type="inclusion stopped", data={ "source": "controller", "event": "inclusion stopped", - "secure": False, }, ) client.driver.receive_event(event) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 757dc6d5364..5dcbee4c5ee 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -164,7 +164,10 @@ async def test_manual(hass): assert result2["data"] == { "url": "ws://localhost:3000", "usb_path": None, - "network_key": None, + "s0_legacy_key": None, + "s2_access_control_key": None, + "s2_authenticated_key": None, + "s2_unauthenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -278,7 +281,10 @@ async def test_supervisor_discovery( await setup.async_setup_component(hass, "persistent_notification", {}) addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -300,7 +306,10 @@ async def test_supervisor_discovery( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -336,7 +345,10 @@ async def test_clean_discovery_on_user_create( await setup.async_setup_component(hass, "persistent_notification", {}) addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -380,7 +392,10 @@ async def test_clean_discovery_on_user_create( assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, - "network_key": None, + "s0_legacy_key": None, + "s2_access_control_key": None, + "s2_authenticated_key": None, + "s2_unauthenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -433,6 +448,7 @@ async def test_abort_hassio_discovery_with_existing_flow( assert result2["reason"] == "already_in_progress" +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery( hass, supervisor, @@ -467,11 +483,28 @@ async def test_usb_discovery( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -491,14 +524,21 @@ async def test_usb_discovery( assert result["type"] == "create_entry" assert result["title"] == TITLE - assert result["data"]["usb_path"] == "/test" - assert result["data"]["integration_created_addon"] is True - assert result["data"]["use_addon"] is True - assert result["data"]["network_key"] == "abc123" + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "use_addon": True, + "integration_created_addon": True, + } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery_addon_not_running( hass, supervisor, @@ -528,18 +568,35 @@ async def test_usb_discovery_addon_not_running( data_schema = result["data_schema"] assert data_schema({}) == { "usb_path": USB_DISCOVERY_INFO["device"], - "network_key": "", + "s0_legacy_key": "", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", } result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"usb_path": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}, + { + "usb_path": USB_DISCOVERY_INFO["device"], + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( hass, "core_zwave_js", - {"options": {"device": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}}, + { + "options": { + "device": USB_DISCOVERY_INFO["device"], + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -559,10 +616,16 @@ async def test_usb_discovery_addon_not_running( assert result["type"] == "create_entry" assert result["title"] == TITLE - assert result["data"]["usb_path"] == USB_DISCOVERY_INFO["device"] - assert result["data"]["integration_created_addon"] is False - assert result["data"]["use_addon"] is True - assert result["data"]["network_key"] == "abc123" + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": USB_DISCOVERY_INFO["device"], + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "use_addon": True, + "integration_created_addon": False, + } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -589,11 +652,28 @@ async def test_discovery_addon_not_running( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -616,7 +696,10 @@ async def test_discovery_addon_not_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -661,11 +744,28 @@ async def test_discovery_addon_not_installed( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -688,7 +788,10 @@ async def test_discovery_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": True, } @@ -808,7 +911,10 @@ async def test_not_addon(hass, supervisor): assert result["data"] == { "url": "ws://localhost:3000", "usb_path": None, - "network_key": None, + "s0_legacy_key": None, + "s2_access_control_key": None, + "s2_authenticated_key": None, + "s2_unauthenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -826,7 +932,10 @@ async def test_addon_running( ): """Test add-on already running on Supervisor.""" addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -852,7 +961,10 @@ async def test_addon_running( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -928,13 +1040,20 @@ async def test_addon_running_already_configured( ): """Test that only one unique instance is allowed when add-on is running.""" addon_options["device"] = "/test_new" - addon_options["network_key"] = "def456" + addon_options["s0_legacy_key"] = "new123" + addon_options["s2_access_control_key"] = "new456" + addon_options["s2_authenticated_key"] = "new789" + addon_options["s2_unauthenticated_key"] = "new987" entry = MockConfigEntry( domain=DOMAIN, data={ "url": "ws://localhost:3000", "usb_path": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", }, title=TITLE, unique_id=1234, @@ -957,7 +1076,10 @@ async def test_addon_running_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test_new" - assert entry.data["network_key"] == "def456" + assert entry.data["s0_legacy_key"] == "new123" + assert entry.data["s2_access_control_key"] == "new456" + assert entry.data["s2_authenticated_key"] == "new789" + assert entry.data["s2_unauthenticated_key"] == "new987" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -988,11 +1110,28 @@ async def test_addon_installed( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1015,7 +1154,10 @@ async def test_addon_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": False, } @@ -1054,11 +1196,28 @@ async def test_addon_installed_start_failure( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1113,11 +1272,28 @@ async def test_addon_installed_failures( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1163,11 +1339,28 @@ async def test_addon_installed_set_options_failure( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "abort" @@ -1192,7 +1385,11 @@ async def test_addon_installed_already_configured( data={ "url": "ws://localhost:3000", "usb_path": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", }, title=TITLE, unique_id=1234, @@ -1215,13 +1412,28 @@ async def test_addon_installed_already_configured( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test_new", "network_key": "def456"} + result["flow_id"], + { + "usb_path": "/test_new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( hass, "core_zwave_js", - {"options": {"device": "/test_new", "network_key": "def456"}}, + { + "options": { + "device": "/test_new", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1236,7 +1448,10 @@ async def test_addon_installed_already_configured( assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test_new" - assert entry.data["network_key"] == "def456" + assert entry.data["s0_legacy_key"] == "new123" + assert entry.data["s2_access_control_key"] == "new456" + assert entry.data["s2_authenticated_key"] == "new789" + assert entry.data["s2_unauthenticated_key"] == "new987" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -1279,11 +1494,28 @@ async def test_addon_not_installed( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], + { + "usb_path": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + }, ) assert set_addon_options.call_args == call( - hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + hass, + "core_zwave_js", + { + "options": { + "device": "/test", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + } + }, ) assert result["type"] == "progress" @@ -1306,7 +1538,10 @@ async def test_addon_not_installed( assert result["data"] == { "url": "ws://host1:3001", "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "use_addon": True, "integration_created_addon": True, } @@ -1431,10 +1666,20 @@ async def test_options_not_addon(hass, client, supervisor, integration): ( {"config": ADDON_DISCOVERY_INFO}, {}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1443,10 +1688,20 @@ async def test_options_not_addon(hass, client, supervisor, integration): ( {"config": ADDON_DISCOVERY_INFO}, {"use_addon": True}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1519,7 +1774,18 @@ async def test_options_addon_running( assert result["type"] == "create_entry" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] - assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] + assert ( + entry.data["s2_access_control_key"] + == new_addon_options["s2_access_control_key"] + ) + assert ( + entry.data["s2_authenticated_key"] == new_addon_options["s2_authenticated_key"] + ) + assert ( + entry.data["s2_unauthenticated_key"] + == new_addon_options["s2_unauthenticated_key"] + ) assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is False assert client.connect.call_count == 2 @@ -1534,13 +1800,20 @@ async def test_options_addon_running( {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, @@ -1599,7 +1872,18 @@ async def test_options_addon_running_no_changes( assert result["type"] == "create_entry" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] - assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] + assert ( + entry.data["s2_access_control_key"] + == new_addon_options["s2_access_control_key"] + ) + assert ( + entry.data["s2_authenticated_key"] == new_addon_options["s2_authenticated_key"] + ) + assert ( + entry.data["s2_unauthenticated_key"] + == new_addon_options["s2_unauthenticated_key"] + ) assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is False assert client.connect.call_count == 2 @@ -1625,13 +1909,20 @@ async def different_device_server_version(*args): {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1705,6 +1996,9 @@ async def test_options_different_device( result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() + # Legacy network key is not reset. + old_addon_options.pop("network_key") + assert set_addon_options.call_count == 2 assert set_addon_options.call_args == call( hass, @@ -1737,13 +2031,20 @@ async def test_options_different_device( {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1755,13 +2056,20 @@ async def test_options_different_device( {}, { "device": "/test", - "network_key": "abc123", + "network_key": "old123", + "s0_legacy_key": "old123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1838,6 +2146,8 @@ async def test_options_addon_restart_failed( result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() + # The legacy network key should not be reset. + old_addon_options.pop("network_key") assert set_addon_options.call_count == 2 assert set_addon_options.call_args == call( hass, @@ -1871,12 +2181,19 @@ async def test_options_addon_restart_failed( { "device": "/test", "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, { "usb_path": "/test", - "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", "log_level": "info", "emulate_hardware": False, }, @@ -1945,10 +2262,20 @@ async def test_options_addon_running_server_info_failure( ( {"config": ADDON_DISCOVERY_INFO}, {}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -1957,10 +2284,20 @@ async def test_options_addon_running_server_info_failure( ( {"config": ADDON_DISCOVERY_INFO}, {"use_addon": True}, - {"device": "/test", "network_key": "abc123"}, + { + "device": "/test", + "network_key": "abc123", + "s0_legacy_key": "abc123", + "s2_access_control_key": "old456", + "s2_authenticated_key": "old789", + "s2_unauthenticated_key": "old987", + }, { "usb_path": "/new", - "network_key": "new123", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", "log_level": "info", "emulate_hardware": False, }, @@ -2048,8 +2385,95 @@ async def test_options_addon_not_installed( assert result["type"] == "create_entry" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] - assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is True assert client.connect.call_count == 2 assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_import_addon_installed( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test import step while add-on already installed on Supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"usb_path": "/test/imported", "network_key": "imported123"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + # the default input should be the imported data + default_input = result["data_schema"]({}) + + assert default_input == { + "usb_path": "/test/imported", + "s0_legacy_key": "imported123", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], default_input + ) + + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + { + "options": { + "device": "/test/imported", + "s0_legacy_key": "imported123", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + } + }, + ) + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test/imported", + "s0_legacy_key": "imported123", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py new file mode 100644 index 00000000000..65bc8e4bddb --- /dev/null +++ b/tests/components/zwave_js/test_device_action.py @@ -0,0 +1,457 @@ +"""The tests for Z-Wave JS device actions.""" +import pytest +import voluptuous_serialize +from zwave_js_server.client import Client +from zwave_js_server.const import CommandClass +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN, device_action +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +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 + + +async def test_get_actions( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected actions from a zwave_js node.""" + node = lock_schlage_be469 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + expected_actions = [ + { + "domain": DOMAIN, + "type": "clear_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "set_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + }, + { + "domain": DOMAIN, + "type": "set_value", + "device_id": device.id, + }, + { + "domain": DOMAIN, + "type": "ping", + "device_id": device.id, + }, + { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": device.id, + "parameter": 3, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + }, + ] + actions = await async_get_device_automations(hass, "action", device.id) + for action in expected_actions: + assert action in actions + + +async def test_get_actions_meter( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected meter actions from a zwave_js node.""" + node = aeon_smart_switch_6 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + actions = await async_get_device_automations(hass, "action", device.id) + filtered_actions = [action for action in actions if action["type"] == "reset_meter"] + assert len(filtered_actions) > 0 + + +async def test_action(hass: HomeAssistant) -> None: + """Test for turn_on and turn_off actions.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_clear_lock_usercode", + }, + "action": { + "domain": DOMAIN, + "type": "clear_lock_usercode", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_lock_usercode", + }, + "action": { + "domain": DOMAIN, + "type": "set_lock_usercode", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + "usercode": "1234", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_refresh_value", + }, + "action": { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": "fake", + "entity_id": "lock.touchscreen_deadbolt", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_ping", + }, + "action": { + "domain": DOMAIN, + "type": "ping", + "device_id": "fake", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_value", + }, + "action": { + "domain": DOMAIN, + "type": "set_value", + "device_id": "fake", + "command_class": 112, + "property": "test", + "value": 1, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_config_parameter", + }, + "action": { + "domain": DOMAIN, + "type": "set_config_parameter", + "device_id": "fake", + "parameter": 3, + "bitmask": None, + "subtype": "2-112-0-3 (Beeper)", + "value": 255, + }, + }, + ] + }, + ) + + clear_lock_usercode = async_mock_service(hass, "zwave_js", "clear_lock_usercode") + hass.bus.async_fire("test_event_clear_lock_usercode") + await hass.async_block_till_done() + assert len(clear_lock_usercode) == 1 + + set_lock_usercode = async_mock_service(hass, "zwave_js", "set_lock_usercode") + hass.bus.async_fire("test_event_set_lock_usercode") + await hass.async_block_till_done() + assert len(set_lock_usercode) == 1 + + refresh_value = async_mock_service(hass, "zwave_js", "refresh_value") + hass.bus.async_fire("test_event_refresh_value") + await hass.async_block_till_done() + assert len(refresh_value) == 1 + + ping = async_mock_service(hass, "zwave_js", "ping") + hass.bus.async_fire("test_event_ping") + await hass.async_block_till_done() + assert len(ping) == 1 + + set_value = async_mock_service(hass, "zwave_js", "set_value") + hass.bus.async_fire("test_event_set_value") + await hass.async_block_till_done() + assert len(set_value) == 1 + + set_config_parameter = async_mock_service(hass, "zwave_js", "set_config_parameter") + hass.bus.async_fire("test_event_set_config_parameter") + await hass.async_block_till_done() + assert len(set_config_parameter) == 1 + + +async def test_get_action_capabilities( + hass: HomeAssistant, + client: Client, + climate_radio_thermostat_ct100_plus: Node, + integration: ConfigEntry, +): + """Test we get the expected action capabilities.""" + 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 refresh_value + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "refresh_value", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "boolean", "name": "refresh_all_values", "optional": True}] + + # Test ping + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "ping", + }, + ) + assert not capabilities + + # Test set_value + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_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"}, + {"type": "boolean", "name": "wait_for_result", "optional": True}, + ] + + # Test enumerated type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 1, + "bitmask": None, + "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_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 10, + "bitmask": None, + "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, + "type": "integer", + "valueMin": 0, + "valueMax": 124, + } + ] + + # Test undefined type param + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "set_config_parameter", + "parameter": 2, + "bitmask": None, + "subtype": f"{node.node_id}-112-0-2 (HVAC Settings)", + }, + ) + assert not capabilities + + +async def test_get_action_capabilities_lock_triggers( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +): + """Test we get the expected action capabilities for lock triggers.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + # Test clear_lock_usercode + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "type": "clear_lock_usercode", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "string", "name": "code_slot", "required": True}] + + # Test set_lock_usercode + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "type": "set_lock_usercode", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + {"type": "string", "name": "code_slot", "required": True}, + {"type": "string", "name": "usercode", "required": True}, + ] + + +async def test_get_action_capabilities_meter_triggers( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test we get the expected action capabilities for meter triggers.""" + node = aeon_smart_switch_6 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + capabilities = await device_action.async_get_action_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": "sensor.meter", + "type": "reset_meter", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"type": "string", "name": "value", "optional": True}] + + +async def test_failure_scenarios( + hass: HomeAssistant, + client: Client, + hank_binary_switch: Node, + integration: ConfigEntry, +): + """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_action.async_call_action_from_config( + hass, {"type": "failed.test", "device_id": device.id}, {}, None + ) + + assert ( + await device_action.async_get_action_capabilities( + hass, {"type": "failed.test", "device_id": device.id} + ) + == {} + ) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 9758d3b0f44..ad176d0168e 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -6,6 +6,9 @@ from homeassistant.components.zwave_js.discovery import ( ZWaveDiscoverySchema, ZWaveValueDiscoverySchema, ) +from homeassistant.components.zwave_js.discovery_data_template import ( + DynamicCurrentTempClimateDataTemplate, +) async def test_iblinds_v2(hass, client, iblinds_v2, integration): @@ -76,3 +79,12 @@ async def test_firmware_version_range_exception(hass): ZWaveValueDiscoverySchema(command_class=1), firmware_version_range=FirmwareVersionRange(), ) + + +async def test_dynamic_climate_data_discovery_template_failure(hass, multisensor_6): + """Test that initing a DynamicCurrentTempClimateDataTemplate with no data raises.""" + node = multisensor_6 + with pytest.raises(ValueError): + DynamicCurrentTempClimateDataTemplate().resolve_data( + node.values[f"{node.node_id}-49-0-Ultraviolet"] + ) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 447b052b8c0..b2cb7bc808e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import call, patch import pytest +from zwave_js_server.event import Event from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.node import Node @@ -124,10 +125,43 @@ async def test_listen_failure(hass, client, error): assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_new_entity_on_value_added(hass, multisensor_6, client, integration): + """Test we create a new entity if a value is added after the fact.""" + node: Node = multisensor_6 + + # Add a value on a random endpoint so we can be sure we should get a new entity + event = Event( + type="value added", + data={ + "source": "node", + "event": "value added", + "nodeId": node.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 10, + "property": "Ultraviolet", + "propertyName": "Ultraviolet", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Ultraviolet", + "ccSpecific": {"sensorType": 27, "scale": 0}, + }, + "value": 0, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None + + async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): """Test we handle a ready node added event.""" dev_reg = dr.async_get(hass) - node = Node(client, multisensor_6_state) + node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" @@ -238,15 +272,28 @@ async def test_start_addon( ): """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" - network_key = "abc123" + s0_legacy_key = "s0_legacy" + s2_access_control_key = "s2_access_control" + s2_authenticated_key = "s2_authenticated" + s2_unauthenticated_key = "s2_unauthenticated" addon_options = { "device": device, - "network_key": network_key, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, } entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", - data={"use_addon": True, "usb_path": device, "network_key": network_key}, + data={ + "use_addon": True, + "usb_path": device, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, + }, ) entry.add_to_hass(hass) @@ -269,15 +316,28 @@ async def test_install_addon( """Test install and start the Z-Wave JS add-on during entry setup.""" addon_installed.return_value["version"] = None device = "/test" - network_key = "abc123" + s0_legacy_key = "s0_legacy" + s2_access_control_key = "s2_access_control" + s2_authenticated_key = "s2_authenticated" + s2_unauthenticated_key = "s2_unauthenticated" addon_options = { "device": device, - "network_key": network_key, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, } entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", - data={"use_addon": True, "usb_path": device, "network_key": network_key}, + data={ + "use_addon": True, + "usb_path": device, + "s0_legacy_key": s0_legacy_key, + "s2_access_control_key": s2_access_control_key, + "s2_authenticated_key": s2_authenticated_key, + "s2_unauthenticated_key": s2_unauthenticated_key, + }, ) entry.add_to_hass(hass) @@ -323,8 +383,27 @@ async def test_addon_info_failure( @pytest.mark.parametrize( - "old_device, new_device, old_network_key, new_network_key", - [("/old_test", "/new_test", "old123", "new123")], + ( + "old_device, new_device, " + "old_s0_legacy_key, new_s0_legacy_key, " + "old_s2_access_control_key, new_s2_access_control_key, " + "old_s2_authenticated_key, new_s2_authenticated_key, " + "old_s2_unauthenticated_key, new_s2_unauthenticated_key" + ), + [ + ( + "/old_test", + "/new_test", + "old123", + "new123", + "old456", + "new456", + "old789", + "new789", + "old987", + "new987", + ) + ], ) async def test_addon_options_changed( hass, @@ -336,12 +415,21 @@ async def test_addon_options_changed( start_addon, old_device, new_device, - old_network_key, - new_network_key, + old_s0_legacy_key, + new_s0_legacy_key, + old_s2_access_control_key, + new_s2_access_control_key, + old_s2_authenticated_key, + new_s2_authenticated_key, + old_s2_unauthenticated_key, + new_s2_unauthenticated_key, ): """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device - addon_options["network_key"] = new_network_key + addon_options["s0_legacy_key"] = new_s0_legacy_key + addon_options["s2_access_control_key"] = new_s2_access_control_key + addon_options["s2_authenticated_key"] = new_s2_authenticated_key + addon_options["s2_unauthenticated_key"] = new_s2_unauthenticated_key entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", @@ -349,7 +437,10 @@ async def test_addon_options_changed( "url": "ws://host1:3001", "use_addon": True, "usb_path": old_device, - "network_key": old_network_key, + "s0_legacy_key": old_s0_legacy_key, + "s2_access_control_key": old_s2_access_control_key, + "s2_authenticated_key": old_s2_authenticated_key, + "s2_unauthenticated_key": old_s2_unauthenticated_key, }, ) entry.add_to_hass(hass) @@ -359,7 +450,10 @@ async def test_addon_options_changed( assert entry.state == ConfigEntryState.LOADED assert entry.data["usb_path"] == new_device - assert entry.data["network_key"] == new_network_key + assert entry.data["s0_legacy_key"] == new_s0_legacy_key + assert entry.data["s2_access_control_key"] == new_s2_access_control_key + assert entry.data["s2_authenticated_key"] == new_s2_authenticated_key + assert entry.data["s2_unauthenticated_key"] == new_s2_unauthenticated_key assert install_addon.call_count == 0 assert start_addon.call_count == 0 @@ -622,3 +716,85 @@ async def test_suggested_area(hass, client, eaton_rf9640_dimmer): entity = ent_reg.async_get(EATON_RF9640_ENTITY) assert dev_reg.async_get(entity.device_id).area_id is not None + + +async def test_node_removed(hass, multisensor_6_state, client, integration): + """Test that device gets removed when node gets removed.""" + dev_reg = dr.async_get(hass) + node = Node(client, deepcopy(multisensor_6_state)) + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device.id + + event = {"node": node, "replaced": False} + + client.driver.controller.emit("node removed", event) + await hass.async_block_till_done() + # Assert device has been removed + assert not dev_reg.async_get(old_device.id) + + +async def test_replace_same_node(hass, multisensor_6_state, client, integration): + """Test when a node is replaced with itself that the device remains.""" + dev_reg = dr.async_get(hass) + node = Node(client, deepcopy(multisensor_6_state)) + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device.id + + event = {"node": node, "replaced": True} + + client.driver.controller.emit("node removed", event) + await hass.async_block_till_done() + # Assert device has remained + assert dev_reg.async_get(old_device.id) + + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + # Assert device has remained + assert dev_reg.async_get(old_device.id) + + +async def test_replace_different_node( + hass, multisensor_6_state, hank_binary_switch_state, client, integration +): + """Test when a node is replaced with a different node.""" + hank_binary_switch_state = deepcopy(hank_binary_switch_state) + multisensor_6_state = deepcopy(multisensor_6_state) + hank_binary_switch_state["nodeId"] = multisensor_6_state["nodeId"] + dev_reg = dr.async_get(hass) + old_node = Node(client, multisensor_6_state) + device_id = f"{client.driver.controller.home_id}-{old_node.node_id}" + new_node = Node(client, hank_binary_switch_state) + event = {"node": old_node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + + event = {"node": old_node, "replaced": True} + + client.driver.controller.emit("node removed", event) + await hass.async_block_till_done() + # Device should still be there after the node was removed + assert device + + event = {"node": new_node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + device = dev_reg.async_get(device.id) + # assert device is new + assert device + assert device.manufacturer == "HANK Electronics Ltd." diff --git a/tests/components/zwave_js/test_migrate.py b/tests/components/zwave_js/test_migrate.py index a1f60c31fce..ff3712b607e 100644 --- a/tests/components/zwave_js/test_migrate.py +++ b/tests/components/zwave_js/test_migrate.py @@ -1,13 +1,443 @@ """Test the Z-Wave JS migration module.""" +import copy +from unittest.mock import patch + import pytest from zwave_js_server.model.node import Node +from homeassistant.components.zwave_js.api import ENTRY_ID, ID, TYPE from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import AIR_TEMPERATURE_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + +# Switch device +ZWAVE_SWITCH_DEVICE_ID = "zwave_switch_device_id" +ZWAVE_SWITCH_DEVICE_NAME = "Z-Wave Switch Device" +ZWAVE_SWITCH_DEVICE_AREA = "Z-Wave Switch Area" +ZWAVE_SWITCH_ENTITY = "switch.zwave_switch_node" +ZWAVE_SWITCH_UNIQUE_ID = "102-6789" +ZWAVE_SWITCH_NAME = "Z-Wave Switch" +ZWAVE_SWITCH_ICON = "mdi:zwave-test-switch" +ZWAVE_POWER_ENTITY = "sensor.zwave_power" +ZWAVE_POWER_UNIQUE_ID = "102-5678" +ZWAVE_POWER_NAME = "Z-Wave Power" +ZWAVE_POWER_ICON = "mdi:zwave-test-power" + +# Multisensor device +ZWAVE_MULTISENSOR_DEVICE_ID = "zwave_multisensor_device_id" +ZWAVE_MULTISENSOR_DEVICE_NAME = "Z-Wave Multisensor Device" +ZWAVE_MULTISENSOR_DEVICE_AREA = "Z-Wave Multisensor Area" +ZWAVE_SOURCE_NODE_ENTITY = "sensor.zwave_source_node" +ZWAVE_SOURCE_NODE_UNIQUE_ID = "52-4321" +ZWAVE_BATTERY_ENTITY = "sensor.zwave_battery_level" +ZWAVE_BATTERY_UNIQUE_ID = "52-1234" +ZWAVE_BATTERY_NAME = "Z-Wave Battery Level" +ZWAVE_BATTERY_ICON = "mdi:zwave-test-battery" +ZWAVE_TAMPERING_ENTITY = "sensor.zwave_tampering" +ZWAVE_TAMPERING_UNIQUE_ID = "52-3456" +ZWAVE_TAMPERING_NAME = "Z-Wave Tampering" +ZWAVE_TAMPERING_ICON = "mdi:zwave-test-tampering" + + +@pytest.fixture(name="zwave_migration_data") +def zwave_migration_data_fixture(hass): + """Return mock zwave migration data.""" + zwave_switch_device = dr.DeviceEntry( + id=ZWAVE_SWITCH_DEVICE_ID, + name_by_user=ZWAVE_SWITCH_DEVICE_NAME, + area_id=ZWAVE_SWITCH_DEVICE_AREA, + ) + zwave_switch_entry = er.RegistryEntry( + entity_id=ZWAVE_SWITCH_ENTITY, + unique_id=ZWAVE_SWITCH_UNIQUE_ID, + platform="zwave", + name=ZWAVE_SWITCH_NAME, + icon=ZWAVE_SWITCH_ICON, + ) + zwave_multisensor_device = dr.DeviceEntry( + id=ZWAVE_MULTISENSOR_DEVICE_ID, + name_by_user=ZWAVE_MULTISENSOR_DEVICE_NAME, + area_id=ZWAVE_MULTISENSOR_DEVICE_AREA, + ) + zwave_source_node_entry = er.RegistryEntry( + entity_id=ZWAVE_SOURCE_NODE_ENTITY, + unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, + platform="zwave", + name="Z-Wave Source Node", + ) + zwave_battery_entry = er.RegistryEntry( + entity_id=ZWAVE_BATTERY_ENTITY, + unique_id=ZWAVE_BATTERY_UNIQUE_ID, + platform="zwave", + name=ZWAVE_BATTERY_NAME, + icon=ZWAVE_BATTERY_ICON, + unit_of_measurement="%", + ) + zwave_power_entry = er.RegistryEntry( + entity_id=ZWAVE_POWER_ENTITY, + unique_id=ZWAVE_POWER_UNIQUE_ID, + platform="zwave", + name=ZWAVE_POWER_NAME, + icon=ZWAVE_POWER_ICON, + unit_of_measurement="W", + ) + zwave_tampering_entry = er.RegistryEntry( + entity_id=ZWAVE_TAMPERING_ENTITY, + unique_id=ZWAVE_TAMPERING_UNIQUE_ID, + platform="zwave", + name=ZWAVE_TAMPERING_NAME, + icon=ZWAVE_TAMPERING_ICON, + unit_of_measurement="", # Test empty string unit normalization. + ) + + zwave_migration_data = { + ZWAVE_SWITCH_ENTITY: { + "node_id": 102, + "node_instance": 1, + "command_class": 37, + "command_class_label": "", + "value_index": 1, + "device_id": zwave_switch_device.id, + "domain": zwave_switch_entry.domain, + "entity_id": zwave_switch_entry.entity_id, + "unique_id": ZWAVE_SWITCH_UNIQUE_ID, + "unit_of_measurement": zwave_switch_entry.unit_of_measurement, + }, + ZWAVE_POWER_ENTITY: { + "node_id": 102, + "node_instance": 1, + "command_class": 50, + "command_class_label": "Power", + "value_index": 8, + "device_id": zwave_switch_device.id, + "domain": zwave_power_entry.domain, + "entity_id": zwave_power_entry.entity_id, + "unique_id": ZWAVE_POWER_UNIQUE_ID, + "unit_of_measurement": zwave_power_entry.unit_of_measurement, + }, + ZWAVE_SOURCE_NODE_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 113, + "command_class_label": "SourceNodeId", + "value_index": 1, + "device_id": zwave_multisensor_device.id, + "domain": zwave_source_node_entry.domain, + "entity_id": zwave_source_node_entry.entity_id, + "unique_id": ZWAVE_SOURCE_NODE_UNIQUE_ID, + "unit_of_measurement": zwave_source_node_entry.unit_of_measurement, + }, + ZWAVE_BATTERY_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 128, + "command_class_label": "Battery Level", + "value_index": 0, + "device_id": zwave_multisensor_device.id, + "domain": zwave_battery_entry.domain, + "entity_id": zwave_battery_entry.entity_id, + "unique_id": ZWAVE_BATTERY_UNIQUE_ID, + "unit_of_measurement": zwave_battery_entry.unit_of_measurement, + }, + ZWAVE_TAMPERING_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 113, + "command_class_label": "Burglar", + "value_index": 10, + "device_id": zwave_multisensor_device.id, + "domain": zwave_tampering_entry.domain, + "entity_id": zwave_tampering_entry.entity_id, + "unique_id": ZWAVE_TAMPERING_UNIQUE_ID, + "unit_of_measurement": zwave_tampering_entry.unit_of_measurement, + }, + } + + mock_device_registry( + hass, + { + zwave_switch_device.id: zwave_switch_device, + zwave_multisensor_device.id: zwave_multisensor_device, + }, + ) + mock_registry( + hass, + { + ZWAVE_SWITCH_ENTITY: zwave_switch_entry, + ZWAVE_SOURCE_NODE_ENTITY: zwave_source_node_entry, + ZWAVE_BATTERY_ENTITY: zwave_battery_entry, + ZWAVE_POWER_ENTITY: zwave_power_entry, + ZWAVE_TAMPERING_ENTITY: zwave_tampering_entry, + }, + ) + + return zwave_migration_data + + +@pytest.fixture(name="zwave_integration") +def zwave_integration_fixture(hass, zwave_migration_data): + """Mock the zwave integration.""" + hass.config.components.add("zwave") + zwave_config_entry = MockConfigEntry(domain="zwave", data={"usb_path": "/dev/test"}) + zwave_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.zwave.async_get_migration_data", + return_value=zwave_migration_data, + ): + yield zwave_config_entry + + +async def test_migrate_zwave( + hass, + zwave_integration, + aeon_smart_switch_6, + multisensor_6, + integration, + hass_ws_client, +): + """Test the Z-Wave to Z-Wave JS migration websocket api.""" + entry = integration + client = await hass_ws_client(hass) + + assert hass.config_entries.async_entries("zwave") + + await client.send_json( + { + ID: 5, + TYPE: "zwave_js/migrate_zwave", + ENTRY_ID: entry.entry_id, + "dry_run": False, + } + ) + msg = await client.receive_json() + result = msg["result"] + + migration_entity_map = { + ZWAVE_SWITCH_ENTITY: "switch.smart_switch_6", + ZWAVE_BATTERY_ENTITY: "sensor.multisensor_6_battery_level", + } + + assert result["zwave_entity_ids"] == [ + ZWAVE_SWITCH_ENTITY, + ZWAVE_POWER_ENTITY, + ZWAVE_SOURCE_NODE_ENTITY, + ZWAVE_BATTERY_ENTITY, + ZWAVE_TAMPERING_ENTITY, + ] + expected_zwave_js_entities = [ + "switch.smart_switch_6", + "sensor.multisensor_6_air_temperature", + "sensor.multisensor_6_illuminance", + "sensor.multisensor_6_humidity", + "sensor.multisensor_6_ultraviolet", + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed", + "binary_sensor.multisensor_6_home_security_motion_detection", + "sensor.multisensor_6_battery_level", + "binary_sensor.multisensor_6_low_battery_level", + "light.smart_switch_6", + "sensor.smart_switch_6_electric_consumed_kwh", + "sensor.smart_switch_6_electric_consumed_w", + "sensor.smart_switch_6_electric_consumed_v", + "sensor.smart_switch_6_electric_consumed_a", + ] + # Assert that both lists have the same items without checking order + assert not set(result["zwave_js_entity_ids"]) ^ set(expected_zwave_js_entities) + assert result["migration_entity_map"] == migration_entity_map + assert result["migrated"] is True + + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + # check the device registry migration + + # check that the migrated entries have correct attributes + multisensor_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-52")}, connections=set() + ) + assert multisensor_device_entry + assert multisensor_device_entry.name_by_user == ZWAVE_MULTISENSOR_DEVICE_NAME + assert multisensor_device_entry.area_id == ZWAVE_MULTISENSOR_DEVICE_AREA + switch_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-102")}, connections=set() + ) + assert switch_device_entry + assert switch_device_entry.name_by_user == ZWAVE_SWITCH_DEVICE_NAME + assert switch_device_entry.area_id == ZWAVE_SWITCH_DEVICE_AREA + + migration_device_map = { + ZWAVE_SWITCH_DEVICE_ID: switch_device_entry.id, + ZWAVE_MULTISENSOR_DEVICE_ID: multisensor_device_entry.id, + } + + assert result["migration_device_map"] == migration_device_map + + # check the entity registry migration + + # this should have been migrated and no longer present under that id + assert not ent_reg.async_is_registered("sensor.multisensor_6_battery_level") + + # these should not have been migrated and is still in the registry + assert ent_reg.async_is_registered(ZWAVE_SOURCE_NODE_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_SOURCE_NODE_ENTITY) + assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID + assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) + assert source_entry.unique_id == ZWAVE_POWER_UNIQUE_ID + assert ent_reg.async_is_registered(ZWAVE_TAMPERING_ENTITY) + tampering_entry = ent_reg.async_get(ZWAVE_TAMPERING_ENTITY) + assert tampering_entry.unique_id == ZWAVE_TAMPERING_UNIQUE_ID + assert ent_reg.async_is_registered("sensor.smart_switch_6_electric_consumed_w") + + # this is the new entity_ids of the zwave_js entities + assert ent_reg.async_is_registered(ZWAVE_SWITCH_ENTITY) + assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) + + # check that the migrated entries have correct attributes + switch_entry = ent_reg.async_get(ZWAVE_SWITCH_ENTITY) + assert switch_entry + assert switch_entry.unique_id == "3245146787.102-37-0-currentValue" + assert switch_entry.name == ZWAVE_SWITCH_NAME + assert switch_entry.icon == ZWAVE_SWITCH_ICON + battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) + assert battery_entry + assert battery_entry.unique_id == "3245146787.52-128-0-level" + assert battery_entry.name == ZWAVE_BATTERY_NAME + assert battery_entry.icon == ZWAVE_BATTERY_ICON + + # check that the zwave config entry has been removed + assert not hass.config_entries.async_entries("zwave") + + # Check that the zwave integration fails entry setup after migration + zwave_config_entry = MockConfigEntry(domain="zwave") + zwave_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(zwave_config_entry.entry_id) + + +async def test_migrate_zwave_dry_run( + hass, + zwave_integration, + aeon_smart_switch_6, + multisensor_6, + integration, + hass_ws_client, +): + """Test the zwave to zwave_js migration websocket api dry run.""" + entry = integration + client = await hass_ws_client(hass) + + await client.send_json( + {ID: 5, TYPE: "zwave_js/migrate_zwave", ENTRY_ID: entry.entry_id} + ) + msg = await client.receive_json() + result = msg["result"] + + migration_entity_map = { + ZWAVE_SWITCH_ENTITY: "switch.smart_switch_6", + ZWAVE_BATTERY_ENTITY: "sensor.multisensor_6_battery_level", + } + + assert result["zwave_entity_ids"] == [ + ZWAVE_SWITCH_ENTITY, + ZWAVE_POWER_ENTITY, + ZWAVE_SOURCE_NODE_ENTITY, + ZWAVE_BATTERY_ENTITY, + ZWAVE_TAMPERING_ENTITY, + ] + expected_zwave_js_entities = [ + "switch.smart_switch_6", + "sensor.multisensor_6_air_temperature", + "sensor.multisensor_6_illuminance", + "sensor.multisensor_6_humidity", + "sensor.multisensor_6_ultraviolet", + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed", + "binary_sensor.multisensor_6_home_security_motion_detection", + "sensor.multisensor_6_battery_level", + "binary_sensor.multisensor_6_low_battery_level", + "light.smart_switch_6", + "sensor.smart_switch_6_electric_consumed_kwh", + "sensor.smart_switch_6_electric_consumed_w", + "sensor.smart_switch_6_electric_consumed_v", + "sensor.smart_switch_6_electric_consumed_a", + ] + # Assert that both lists have the same items without checking order + assert not set(result["zwave_js_entity_ids"]) ^ set(expected_zwave_js_entities) + assert result["migration_entity_map"] == migration_entity_map + + dev_reg = dr.async_get(hass) + + multisensor_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-52")}, connections=set() + ) + assert multisensor_device_entry + assert multisensor_device_entry.name_by_user is None + assert multisensor_device_entry.area_id is None + switch_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-102")}, connections=set() + ) + assert switch_device_entry + assert switch_device_entry.name_by_user is None + assert switch_device_entry.area_id is None + + migration_device_map = { + ZWAVE_SWITCH_DEVICE_ID: switch_device_entry.id, + ZWAVE_MULTISENSOR_DEVICE_ID: multisensor_device_entry.id, + } + + assert result["migration_device_map"] == migration_device_map + + assert result["migrated"] is False + + ent_reg = er.async_get(hass) + + # no real migration should have been done + assert ent_reg.async_is_registered("switch.smart_switch_6") + assert ent_reg.async_is_registered("sensor.multisensor_6_battery_level") + assert ent_reg.async_is_registered("sensor.smart_switch_6_electric_consumed_w") + + assert ent_reg.async_is_registered(ZWAVE_SOURCE_NODE_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_SOURCE_NODE_ENTITY) + assert source_entry + assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID + + assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) + battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) + assert battery_entry + assert battery_entry.unique_id == ZWAVE_BATTERY_UNIQUE_ID + + assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) + power_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) + assert power_entry + assert power_entry.unique_id == ZWAVE_POWER_UNIQUE_ID + + # check that the zwave config entry has not been removed + assert hass.config_entries.async_entries("zwave") + + # Check that the zwave integration can be setup after dry run + zwave_config_entry = zwave_integration + with patch("openzwave.option.ZWaveOption"), patch("openzwave.network.ZWaveNetwork"): + assert await hass.config_entries.async_setup(zwave_config_entry.entry_id) + + +async def test_migrate_zwave_not_setup( + hass, aeon_smart_switch_6, multisensor_6, integration, hass_ws_client +): + """Test the zwave to zwave_js migration websocket without zwave setup.""" + entry = integration + client = await hass_ws_client(hass) + + await client.send_json( + {ID: 5, TYPE: "zwave_js/migrate_zwave", ENTRY_ID: entry.entry_id} + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_not_loaded" + assert msg["error"]["message"] == "Integration zwave is not loaded" + async def test_unique_id_migration_dupes( hass, multisensor_6_state, client, integration @@ -48,7 +478,7 @@ async def test_unique_id_migration_dupes( assert entity_entry.unique_id == old_unique_id_2 # Add a ready node, unique ID should be migrated - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -91,7 +521,7 @@ async def test_unique_id_migration(hass, multisensor_6_state, client, integratio assert entity_entry.unique_id == old_unique_id # Add a ready node, unique ID should be migrated - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -135,7 +565,7 @@ async def test_unique_id_migration_property_key( assert entity_entry.unique_id == old_unique_id # Add a ready node, unique ID should be migrated - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -170,7 +600,7 @@ async def test_unique_id_migration_notification_binary_sensor( assert entity_entry.unique_id == old_unique_id # Add a ready node, unique ID should be migrated - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) event = {"node": node} client.driver.controller.emit("node added", event) @@ -187,12 +617,15 @@ async def test_old_entity_migration( hass, hank_binary_switch_state, client, integration ): """Test old entity on a different endpoint is migrated to a new one.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], + model=hank_binary_switch_state["deviceConfig"]["label"], ) SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" @@ -230,12 +663,15 @@ async def test_different_endpoint_migration_status_sensor( hass, hank_binary_switch_state, client, integration ): """Test that the different endpoint migration logic skips over the status sensor.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], + model=hank_binary_switch_state["deviceConfig"]["label"], ) SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_status_sensor" @@ -271,12 +707,15 @@ async def test_skip_old_entity_migration_for_multiple( hass, hank_binary_switch_state, client, integration ): """Test that multiple entities of the same value but on a different endpoint get skipped.""" - node = Node(client, hank_binary_switch_state) + node = Node(client, copy.deepcopy(hank_binary_switch_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], + model=hank_binary_switch_state["deviceConfig"]["label"], ) SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" @@ -328,12 +767,15 @@ async def test_old_entity_migration_notification_binary_sensor( hass, multisensor_6_state, client, integration ): """Test old entity on a different endpoint is migrated to a new one for a notification binary sensor.""" - node = Node(client, multisensor_6_state) + node = Node(client, copy.deepcopy(multisensor_6_state)) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) device = dev_reg.async_get_or_create( - config_entry_id=integration.entry_id, identifiers={get_device_id(client, node)} + config_entry_id=integration.entry_id, + identifiers={get_device_id(client, node)}, + manufacturer=multisensor_6_state["deviceConfig"]["manufacturer"], + model=multisensor_6_state["deviceConfig"]["label"], ) entity_name = NOTIFICATION_MOTION_BINARY_SENSOR.split(".")[1] diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 43f44f0bba0..5ed0804723c 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -5,6 +5,7 @@ from homeassistant.const import STATE_UNKNOWN DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" PROTECTION_SELECT_ENTITY = "select.family_room_combo_local_protection_state" +MULTILEVEL_SWITCH_SELECT_ENTITY = "select.front_door_siren" async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): @@ -199,3 +200,75 @@ async def test_protection_select(hass, client, inovelli_lzw36, integration): state = hass.states.get(PROTECTION_SELECT_ENTITY) assert state.state == STATE_UNKNOWN + + +async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integration): + """Test Multilevel Switch CC based select entity.""" + node = fortrezz_ssa1_siren + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + + assert state + assert state.state == "Off" + attr = state.attributes + assert attr["options"] == [ + "Off", + "Strobe ONLY", + "Siren ONLY", + "Siren & Strobe FULL Alarm", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": MULTILEVEL_SWITCH_SELECT_ENTITY, "option": "Strobe ONLY"}, + 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"] == { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + }, + } + assert args["value"] == 33 + + 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": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 33, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + assert state.state == "Strobe ONLY" diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index b595b6462b3..a18bead36c2 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,18 +1,26 @@ """Test the Z-Wave JS sensor platform.""" +import copy + +from zwave_js_server.const.command_class.meter import MeterType from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, + ATTR_METER_TYPE_NAME, ATTR_VALUE, DOMAIN, SERVICE_RESET_METER, ) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_ICON, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -161,24 +169,30 @@ async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integrati ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "dead" + assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:robot-dead" event = Event( "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "awake" + assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:eye" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "asleep" + assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:sleep" event = Event( "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert ( + hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:heart-pulse" + ) # Disconnect the client and make sure the entity is still available await client.disconnect() @@ -268,3 +282,85 @@ async def test_reset_meter( assert args["args"] == [{"type": 1, "targetValue": 2}] client.async_send_command_no_wait.reset_mock() + + +async def test_meter_attributes( + hass, + client, + aeon_smart_switch_6, + integration, +): + """Test meter entity attributes.""" + state = hass.states.get(METER_ENERGY_SENSOR) + assert state + assert state.attributes[ATTR_METER_TYPE] == MeterType.ELECTRIC.value + assert state.attributes[ATTR_METER_TYPE_NAME] == MeterType.ELECTRIC.name + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + + +async def test_special_meters(hass, aeon_smart_switch_6_state, client, integration): + """Test meters that have special handling.""" + node_data = copy.deepcopy( + aeon_smart_switch_6_state + ) # Copy to allow modification in tests. + # Add an ElectricScale.KILOVOLT_AMPERE_HOUR value to the state so we can test that + # it is handled differently (no device class) + node_data["values"].append( + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kVah_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Electric Consumed [kVah]", + "unit": "kVah", + "ccSpecific": {"meterType": 1, "rateType": 1, "scale": 1}, + }, + "value": 659.813, + }, + ) + # Add an ElectricScale.KILOVOLT_AMPERE_REACTIVE value to the state so we can test that + # it is handled differently (no device class) + node_data["values"].append( + { + "endpoint": 11, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kVa_reactive_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Electric Consumed [kVa reactive]", + "unit": "kVa reactive", + "ccSpecific": {"meterType": 1, "rateType": 1, "scale": 7}, + }, + "value": 659.813, + }, + ) + node = Node(client, node_data) + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + state = hass.states.get("sensor.smart_switch_6_electric_consumed_kvah_10") + assert state + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + + state = hass.states.get("sensor.smart_switch_6_electric_consumed_kva_reactive_11") + assert state + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT diff --git a/tests/conftest.py b/tests/conftest.py index ce8e244f420..9ee6bbc680b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -331,6 +331,17 @@ def hass_client(hass, aiohttp_client, hass_access_token): return auth_client +@pytest.fixture +def hass_client_no_auth(hass, aiohttp_client): + """Return an unauthenticated HTTP client.""" + + async def client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app) + + return client + + @pytest.fixture def current_request(): """Mock current request.""" @@ -477,11 +488,23 @@ async def mqtt_mock(hass, mqtt_client_mock, mqtt_config): return component +@pytest.fixture +def mock_get_source_ip(): + """Mock network util's async_get_source_ip.""" + with patch( + "homeassistant.components.network.util.async_get_source_ip", + return_value="10.10.10.10", + ): + yield + + @pytest.fixture def mock_zeroconf(): """Mock zeroconf.""" - with patch("homeassistant.components.zeroconf.models.HaZeroconf") as mock_zc: - yield mock_zc.return_value + with patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True), patch( + "homeassistant.components.zeroconf.HaAsyncServiceBrowser", autospec=True + ): + yield @pytest.fixture @@ -613,9 +636,9 @@ def 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 + stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None with patch( - "homeassistant.components.recorder.Recorder.async_hourly_statistics", + "homeassistant.components.recorder.Recorder.async_periodic_statistics", side_effect=stats, autospec=True, ): diff --git a/tests/fixtures/efergy_budget.json b/tests/fixtures/efergy/efergy_budget.json similarity index 100% rename from tests/fixtures/efergy_budget.json rename to tests/fixtures/efergy/efergy_budget.json diff --git a/tests/fixtures/efergy_cost.json b/tests/fixtures/efergy/efergy_cost.json similarity index 100% rename from tests/fixtures/efergy_cost.json rename to tests/fixtures/efergy/efergy_cost.json diff --git a/tests/fixtures/efergy_current_values_multi.json b/tests/fixtures/efergy/efergy_current_values_multi.json similarity index 100% rename from tests/fixtures/efergy_current_values_multi.json rename to tests/fixtures/efergy/efergy_current_values_multi.json diff --git a/tests/fixtures/efergy_current_values_single.json b/tests/fixtures/efergy/efergy_current_values_single.json similarity index 100% rename from tests/fixtures/efergy_current_values_single.json rename to tests/fixtures/efergy/efergy_current_values_single.json diff --git a/tests/fixtures/efergy_energy.json b/tests/fixtures/efergy/efergy_energy.json similarity index 100% rename from tests/fixtures/efergy_energy.json rename to tests/fixtures/efergy/efergy_energy.json diff --git a/tests/fixtures/efergy_instant.json b/tests/fixtures/efergy/efergy_instant.json similarity index 100% rename from tests/fixtures/efergy_instant.json rename to tests/fixtures/efergy/efergy_instant.json diff --git a/tests/fixtures/plex/library_movies_collections.xml b/tests/fixtures/plex/library_movies_collections.xml new file mode 100644 index 00000000000..a5ca772f36f --- /dev/null +++ b/tests/fixtures/plex/library_movies_collections.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_movies_metadata.xml b/tests/fixtures/plex/library_movies_metadata.xml new file mode 100644 index 00000000000..e05dfabaaae --- /dev/null +++ b/tests/fixtures/plex/library_movies_metadata.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_movies_size.xml b/tests/fixtures/plex/library_movies_size.xml new file mode 100644 index 00000000000..3ad67aed531 --- /dev/null +++ b/tests/fixtures/plex/library_movies_size.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/plex/library_music_collections.xml b/tests/fixtures/plex/library_music_collections.xml new file mode 100644 index 00000000000..59f36c153b2 --- /dev/null +++ b/tests/fixtures/plex/library_music_collections.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_music_metadata.xml b/tests/fixtures/plex/library_music_metadata.xml new file mode 100644 index 00000000000..d9c6a511f82 --- /dev/null +++ b/tests/fixtures/plex/library_music_metadata.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_music_size.xml b/tests/fixtures/plex/library_music_size.xml new file mode 100644 index 00000000000..a7418df8488 --- /dev/null +++ b/tests/fixtures/plex/library_music_size.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/plex/library_tvshows_collections.xml b/tests/fixtures/plex/library_tvshows_collections.xml new file mode 100644 index 00000000000..914d99bfa91 --- /dev/null +++ b/tests/fixtures/plex/library_tvshows_collections.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_tvshows_metadata.xml b/tests/fixtures/plex/library_tvshows_metadata.xml new file mode 100644 index 00000000000..6aace99f521 --- /dev/null +++ b/tests/fixtures/plex/library_tvshows_metadata.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/plex/library_tvshows_most_recent.xml b/tests/fixtures/plex/library_tvshows_most_recent.xml new file mode 100644 index 00000000000..3e9bd49f66e --- /dev/null +++ b/tests/fixtures/plex/library_tvshows_most_recent.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/tests/fixtures/renault/action.set_ac_start.json b/tests/fixtures/renault/action.set_ac_start.json new file mode 100644 index 00000000000..7aca3269a61 --- /dev/null +++ b/tests/fixtures/renault/action.set_ac_start.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "HvacStart", + "id": "guid", + "attributes": { "action": "start", "targetTemperature": 21.0 } + } +} diff --git a/tests/fixtures/renault/action.set_ac_stop.json b/tests/fixtures/renault/action.set_ac_stop.json new file mode 100644 index 00000000000..df7a94cbf78 --- /dev/null +++ b/tests/fixtures/renault/action.set_ac_stop.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "HvacStart", + "id": "guid", + "attributes": { "action": "cancel" } + } +} diff --git a/tests/fixtures/renault/action.set_charge_mode.json b/tests/fixtures/renault/action.set_charge_mode.json new file mode 100644 index 00000000000..60fa5a19e74 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_mode.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargeMode", + "id": "guid", + "attributes": { "action": "schedule_mode" } + } +} \ No newline at end of file diff --git a/tests/fixtures/renault/action.set_charge_schedules.json b/tests/fixtures/renault/action.set_charge_schedules.json new file mode 100644 index 00000000000..7f60826b826 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_schedules.json @@ -0,0 +1,38 @@ +{ + "data": { + "type": "ChargeSchedule", + "id": "guid", + "attributes": { + "schedules": [ + { + "id": 1, + "activated": true, + "tuesday": { + "startTime": "T04:30Z", + "duration": 420 + }, + "wednesday": { + "startTime": "T22:30Z", + "duration": 420 + }, + "thursday": { + "startTime": "T22:00Z", + "duration": 420 + }, + "friday": { + "startTime": "T23:30Z", + "duration": 480 + }, + "saturday": { + "startTime": "T18:30Z", + "duration": 120 + }, + "sunday": { + "startTime": "T12:45Z", + "duration": 45 + } + } + ] + } + } +} diff --git a/tests/fixtures/renault/action.set_charge_start.json b/tests/fixtures/renault/action.set_charge_start.json new file mode 100644 index 00000000000..3adb70514b4 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_start.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargingStart", + "id": "guid", + "attributes": { "action": "start" } + } +} diff --git a/tests/fixtures/renault/charging_settings.json b/tests/fixtures/renault/charging_settings.json new file mode 100644 index 00000000000..466353bb081 --- /dev/null +++ b/tests/fixtures/renault/charging_settings.json @@ -0,0 +1,87 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "mode": "scheduled", + "schedules": [ + { + "id": 1, + "activated": true, + "monday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "tuesday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "wednesday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "thursday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "friday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "saturday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "sunday": { + "startTime": "T00:00Z", + "duration": 450 + } + }, + { + "id": 2, + "activated": true, + "monday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "tuesday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "wednesday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "thursday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "friday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "saturday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "sunday": { + "startTime": "T23:30Z", + "duration": 15 + } + }, + { + "id": 3, + "activated": false + }, + { + "id": 4, + "activated": false + }, + { + "id": 5, + "activated": false + } + ] + } + } +} diff --git a/tests/fixtures/renault/location.json b/tests/fixtures/renault/location.json new file mode 100644 index 00000000000..bae4474521f --- /dev/null +++ b/tests/fixtures/renault/location.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "gpsLatitude": 48.1234567, + "gpsLongitude": 11.1234567, + "lastUpdateTime": "2020-02-18T16:58:38Z" + } + } +} diff --git a/tests/fixtures/tado/zone_states.json b/tests/fixtures/tado/zone_states.json new file mode 100644 index 00000000000..c5bd0dfbe2c --- /dev/null +++ b/tests/fixtures/tado/zone_states.json @@ -0,0 +1,292 @@ +{ + "zoneStates": { + "1": { + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.50, + "fahrenheit": 68.90 + } + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.50, + "fahrenheit": 68.90 + } + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T17:00:00Z", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 21.00, + "fahrenheit": 69.80 + } + } + }, + "nextTimeBlock": { + "start": "2020-03-10T17:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 0.00, + "timestamp": "2020-03-10T07:47:45.978Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.65, + "fahrenheit": 69.17, + "timestamp": "2020-03-10T07:44:11.947Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 45.20, + "timestamp": "2020-03-10T07:44:11.947Z" + } + } + }, + "2": { + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HOT_WATER", + "power": "ON", + "temperature": { + "celsius": 65.00, + "fahrenheit": 149.00 + } + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T22:00:00Z", + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + } + }, + "nextTimeBlock": { + "start": "2020-03-10T22:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": {}, + "sensorDataPoints": {} + }, + "3": { + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.57, + "timestamp": "2020-03-05T03:57:38.850Z", + "celsius": 24.76, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:57:38.850Z", + "percentage": 60.9, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:01:07.162Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + } + }, + "4": { + "activityDataPoints": {}, + "preparation": null, + "openWindow": null, + "tadoMode": "HOME", + "nextScheduleChange": { + "setting": { + "temperature": { + "fahrenheit": 149, + "celsius": 65 + }, + "type": "HOT_WATER", + "power": "ON" + }, + "start": "2020-03-26T05:00:00Z" + }, + "nextTimeBlock": { + "start": "2020-03-26T05:00:00.000Z" + }, + "overlay": { + "setting": { + "temperature": { + "celsius": 30, + "fahrenheit": 86 + }, + "type": "HOT_WATER", + "power": "ON" + }, + "termination": { + "type": "TADO_MODE", + "projectedExpiry": "2020-03-26T05:00:00Z", + "typeSkillBasedApp": "TADO_MODE" + }, + "type": "MANUAL" + }, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "sensorDataPoints": {}, + "overlayType": "MANUAL", + "link": { + "state": "ONLINE" + }, + "setting": { + "type": "HOT_WATER", + "temperature": { + "fahrenheit": 86, + "celsius": 30 + }, + "power": "ON" + } + }, + "5": { + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 20.00, + "fahrenheit": 68.00 + }, + "fanSpeed": "AUTO", + "swing": "ON" + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-28T04:30:00Z", + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 23.00, + "fahrenheit": 73.40 + }, + "fanSpeed": "AUTO", + "swing": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-28T04:30:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-27T23:02:22.260Z", + "type": "POWER", + "value": "ON" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.88, + "fahrenheit": 69.58, + "timestamp": "2020-03-28T02:09:27.830Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 42.30, + "timestamp": "2020-03-28T02:09:27.830Z" + } + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json b/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json new file mode 100644 index 00000000000..d8973f2688e --- /dev/null +++ b/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json @@ -0,0 +1,350 @@ +{ + "nodeId": 80, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 267, + "productType": 787, + "firmwareVersion": "1.11", + "name": "Front Door Siren", + "location": "Outside", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa1_ssa2.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA1/SSA2", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 785, + "productId": 267 + }, + { + "productType": 787, + "productId": 264 + }, + { + "productType": 787, + "productId": 267 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA1/SSA2", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 80, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "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": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 787 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 267 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.11"] + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay before accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay, from the time the siren-strobe turns on", + "label": "Delay before accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "unit": "Seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0313:0x010b:1.11", + "statistics": { + "commandsTX": 12, + "commandsRX": 64, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 2 + } +} diff --git a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json index 0f90d2ae147..98ae03afbf2 100644 --- a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json +++ b/tests/fixtures/zwave_js/nortek_thermostat_added_event.json @@ -251,5 +251,6 @@ } } ] - } -} \ No newline at end of file + }, + "result": {} +} diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index c5b75b84342..e79250a084d 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -7,6 +7,7 @@ from homeassistant.helpers.check_config import ( CheckConfigError, async_check_ha_config_file, ) +from homeassistant.requirements import RequirementsNotFound from tests.common import mock_platform, patch_yaml_files @@ -75,7 +76,7 @@ async def test_component_platform_not_found(hass): assert res.keys() == {"homeassistant"} assert res.errors[0] == CheckConfigError( - "Component error: beer - Integration 'beer' not found.", None, None + "Integration error: beer - Integration 'beer' not found.", None, None ) # Only 1 error expected @@ -83,6 +84,42 @@ async def test_component_platform_not_found(hass): assert not res.errors +async def test_component_requirement_not_found(hass): + """Test errors if component with a requirement not found not found.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "test_custom_component:"} + with patch( + "homeassistant.helpers.check_config.async_get_integration_with_requirements", + side_effect=RequirementsNotFound("test_custom_component", ["any"]), + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert res.errors[0] == CheckConfigError( + "Integration error: test_custom_component - Requirements for test_custom_component not found: ['any'].", + None, + None, + ) + + # Only 1 error expected + res.errors.pop(0) + assert not res.errors + + +async def test_component_not_found_safe_mode(hass): + """Test no errors if component not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_platform_not_found_2(hass): """Test errors if component or platform not found.""" # Make sure they don't exist @@ -103,6 +140,21 @@ async def test_component_platform_not_found_2(hass): assert not res.errors +async def test_platform_not_found_safe_mode(hass): + """Test no errors if platform not found in safe_mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + assert res["light"] == [] + + assert not res.errors + + async def test_package_invalid(hass): """Test a valid platform setup.""" files = { diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index b5257e635af..52dda703f1e 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -154,7 +154,7 @@ async def test_abort_if_oauth_error( hass, flow_handler, local_impl, - aiohttp_client, + hass_client_no_auth, aioclient_mock, current_request_with_host, ): @@ -191,7 +191,7 @@ async def test_abort_if_oauth_error( f"&state={state}&scope=read+write" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" @@ -274,7 +274,7 @@ async def test_full_flow( hass, flow_handler, local_impl, - aiohttp_client, + hass_client_no_auth, aioclient_mock, current_request_with_host, ): @@ -311,7 +311,7 @@ async def test_full_flow( f"&state={state}&scope=read+write" ) - client = await aiohttp_client(hass.http.app) + client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 8142f563f01..21811c3bfdc 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -707,13 +707,15 @@ async def test_setup_source(hass): assert entity.entity_sources(hass) == { "test_domain.platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": platform.config_entry.entry_id, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_CONFIG_ENTRY, }, } diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 64b075b685a..5522956c81c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -38,6 +38,12 @@ def _set_up_units(hass): ) +def render(hass, template_str, variables=None): + """Create render info from template.""" + tmp = template.Template(template_str, hass) + return tmp.async_render(variables) + + def render_to_info(hass, template_str, variables=None): """Create render info from template.""" tmp = template.Template(template_str, hass) @@ -196,8 +202,8 @@ def test_iterating_domain_states(hass): ) -def test_float(hass): - """Test float.""" +def test_float_function(hass): + """Test float function.""" hass.states.async_set("sensor.temperature", "12") assert ( @@ -219,6 +225,55 @@ def test_float(hass): == "forgiving" ) + assert render(hass, "{{ float('bad', 1) }}") == 1 + assert render(hass, "{{ float('bad', default=1) }}") == 1 + + +def test_float_filter(hass): + """Test float filter.""" + hass.states.async_set("sensor.temperature", "12") + + assert render(hass, "{{ states.sensor.temperature.state | float }}") == 12.0 + assert render(hass, "{{ states.sensor.temperature.state | float > 11 }}") is True + assert render(hass, "{{ 'bad' | float }}") == 0 + assert render(hass, "{{ 'bad' | float(1) }}") == 1 + assert render(hass, "{{ 'bad' | float(default=1) }}") == 1 + + +@pytest.mark.parametrize( + "value, expected", + [ + (0, True), + (0.0, True), + ("0", True), + ("0.0", True), + (True, True), + (False, True), + ("True", False), + ("False", False), + (None, False), + ("None", False), + ("horse", False), + (math.pi, True), + (math.nan, False), + (math.inf, False), + ("nan", False), + ("inf", False), + ], +) +def test_isnumber(hass, value, expected): + """Test is_number.""" + assert ( + template.Template("{{ is_number(value) }}", hass).async_render({"value": value}) + == expected + ) + assert ( + template.Template("{{ value | is_number }}", hass).async_render( + {"value": value} + ) + == expected + ) + def test_rounding_value(hass): """Test rounding value.""" @@ -260,8 +315,8 @@ def test_rounding_value(hass): ) -def test_rounding_value_get_original_value_on_error(hass): - """Test rounding value get original value on error.""" +def test_rounding_value_on_error(hass): + """Test rounding value handling of error.""" assert template.Template("{{ None | round }}", hass).async_render() is None assert ( @@ -269,6 +324,9 @@ def test_rounding_value_get_original_value_on_error(hass): == "no_number" ) + # Test handling of default return value + assert render(hass, "{{ 'no_number' | round(default=1) }}") == 1 + def test_multiply(hass): """Test multiply.""" @@ -282,6 +340,10 @@ def test_multiply(hass): == out ) + # Test handling of default return value + assert render(hass, "{{ 'no_number' | multiply(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | multiply(10, default=1) }}") == 1 + def test_logarithm(hass): """Test logarithm.""" @@ -308,6 +370,12 @@ def test_logarithm(hass): == expected ) + # Test handling of default return value + assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 + assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 + def test_sine(hass): """Test sine.""" @@ -325,6 +393,13 @@ def test_sine(hass): template.Template("{{ %s | sin | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 + assert render(hass, "{{ sin('no_number', 1) }}") == 1 + assert render(hass, "{{ sin('no_number', default=1) }}") == 1 def test_cos(hass): @@ -343,6 +418,13 @@ def test_cos(hass): template.Template("{{ %s | cos | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1 + assert render(hass, "{{ sin('no_number', 1) }}") == 1 + assert render(hass, "{{ sin('no_number', default=1) }}") == 1 def test_tan(hass): @@ -361,6 +443,13 @@ def test_tan(hass): template.Template("{{ %s | tan | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | tan(1) }}") == 1 + assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1 + assert render(hass, "{{ tan('no_number', 1) }}") == 1 + assert render(hass, "{{ tan('no_number', default=1) }}") == 1 def test_sqrt(hass): @@ -379,6 +468,13 @@ def test_sqrt(hass): template.Template("{{ %s | sqrt | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ sqrt({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1 + assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', 1) }}") == 1 + assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1 def test_arc_sine(hass): @@ -399,6 +495,13 @@ def test_arc_sine(hass): template.Template("{{ %s | asin | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | asin(1) }}") == 1 + assert render(hass, "{{ 'no_number' | asin(default=1) }}") == 1 + assert render(hass, "{{ asin('no_number', 1) }}") == 1 + assert render(hass, "{{ asin('no_number', default=1) }}") == 1 def test_arc_cos(hass): @@ -419,6 +522,13 @@ def test_arc_cos(hass): template.Template("{{ %s | acos | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | acos(1) }}") == 1 + assert render(hass, "{{ 'no_number' | acos(default=1) }}") == 1 + assert render(hass, "{{ acos('no_number', 1) }}") == 1 + assert render(hass, "{{ acos('no_number', default=1) }}") == 1 def test_arc_tan(hass): @@ -441,6 +551,13 @@ def test_arc_tan(hass): template.Template("{{ %s | atan | round(3) }}" % value, hass).async_render() == expected ) + assert render(hass, f"{{{{ atan({value}) | round(3) }}}}") == expected + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | atan(1) }}") == 1 + assert render(hass, "{{ 'no_number' | atan(default=1) }}") == 1 + assert render(hass, "{{ atan('no_number', 1) }}") == 1 + assert render(hass, "{{ atan('no_number', default=1) }}") == 1 def test_arc_tan2(hass): @@ -475,6 +592,12 @@ def test_arc_tan2(hass): == expected ) + # Test handling of default return value + assert render(hass, "{{ ('duck', 'goose') | atan2(1) }}") == 1 + assert render(hass, "{{ ('duck', 'goose') | atan2(default=1) }}") == 1 + assert render(hass, "{{ atan2('duck', 'goose', 1) }}") == 1 + assert render(hass, "{{ atan2('duck', 'goose', default=1) }}") == 1 + def test_strptime(hass): """Test the parse timestamp method.""" @@ -497,6 +620,10 @@ def test_strptime(hass): assert template.Template(temp, hass).async_render() == expected + # Test handling of default return value + assert render(hass, "{{ strptime('invalid', '%Y', 1) }}") == 1 + assert render(hass, "{{ strptime('invalid', '%Y', default=1) }}") == 1 + def test_timestamp_custom(hass): """Test the timestamps to custom filter.""" @@ -519,6 +646,10 @@ def test_timestamp_custom(hass): assert template.Template(f"{{{{ {inp} | {fil} }}}}", hass).async_render() == out + # Test handling of default return value + assert render(hass, "{{ None | timestamp_custom('invalid', True, 1) }}") == 1 + assert render(hass, "{{ None | timestamp_custom(default=1) }}") == 1 + def test_timestamp_local(hass): """Test the timestamps to local filter.""" @@ -530,6 +661,10 @@ def test_timestamp_local(hass): == out ) + # Test handling of default return value + assert render(hass, "{{ None | timestamp_local(1) }}") == 1 + assert render(hass, "{{ None | timestamp_local(default=1) }}") == 1 + @pytest.mark.parametrize( "input", @@ -667,6 +802,10 @@ def test_timestamp_utc(hass): == out ) + # Test handling of default return value + assert render(hass, "{{ None | timestamp_utc(1) }}") == 1 + assert render(hass, "{{ None | timestamp_utc(default=1) }}") == 1 + def test_as_timestamp(hass): """Test the as_timestamp function.""" @@ -685,6 +824,12 @@ def test_as_timestamp(hass): ) assert template.Template(tpl, hass).async_render() == 1706951424.0 + # Test handling of default return value + assert render(hass, "{{ 'invalid' | as_timestamp(1) }}") == 1 + assert render(hass, "{{ 'invalid' | as_timestamp(default=1) }}") == 1 + assert render(hass, "{{ as_timestamp('invalid', 1) }}") == 1 + assert render(hass, "{{ as_timestamp('invalid', default=1) }}") == 1 + @patch.object(random, "choice") def test_random_every_time(test_choice, hass): @@ -1110,6 +1255,17 @@ def test_regex_replace(hass): assert tpl.async_render() == ["Home Assistant test"] +def test_regex_findall(hass): + """Test regex_findall method.""" + tpl = template.Template( + """ +{{ 'Flight from JFK to LHR' | regex_findall('([A-Z]{3})') }} + """, + hass, + ) + assert tpl.async_render() == ["JFK", "LHR"] + + def test_regex_findall_index(hass): """Test regex_findall_index method.""" tpl = template.Template( @@ -1599,6 +1755,7 @@ async def test_device_id(hass): config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, model="test", + name="test", ) entity_entry = entity_registry.async_get_or_create( "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id @@ -1611,13 +1768,11 @@ async def test_device_id(hass): 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) + 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, "{{ '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}') }}}}" @@ -1629,6 +1784,10 @@ async def test_device_id(hass): assert_result_info(info, device_entry.id) assert info.rate_limit is None + info = render_to_info(hass, "{{ device_id('test') }}") + 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.""" diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 1a96568f8ef..05ebb8fb0e5 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -74,7 +74,7 @@ def test_component_platform_not_found(mock_is_file, loop): assert res["components"].keys() == {"homeassistant"} assert res["except"] == { check_config.ERROR_STR: [ - "Component error: beer - Integration 'beer' not found." + "Integration error: beer - Integration 'beer' not found." ] } assert res["secret_cache"] == {} diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 3eeb06d056c..1ad64e58bd7 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -678,6 +678,7 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap(hass await bootstrap._async_set_up_integrations( hass, {"normal_integration": {}, "an_after_dep": {}} ) + await hass.async_block_till_done() assert integrations[0] != {} assert "an_after_dep" in integrations[0] @@ -686,3 +687,35 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap(hass assert "normal_integration" in hass.config.components assert order == ["an_after_dep", "normal_integration"] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_warning_logged_on_wrap_up_timeout(hass, caplog): + """Test we log a warning on bootstrap timeout.""" + + def gen_domain_setup(domain): + async def async_setup(hass, config): + await asyncio.sleep(0.1) + + async def _background_task(): + await asyncio.sleep(0.2) + + await hass.async_create_task(_background_task()) + return True + + return async_setup + + mock_integration( + hass, + MockModule( + domain="normal_integration", + async_setup=gen_domain_setup("normal_integration"), + partial_manifest={}, + ), + ) + + with patch.object(bootstrap, "WRAP_UP_TIMEOUT", 0): + await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) + await hass.async_block_till_done() + + assert "Setup timed out for bootstrap - moving forward" in caplog.text diff --git a/tests/test_loader.py b/tests/test_loader.py index 9786c9fdcfb..892e2da9c51 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -141,7 +141,7 @@ async def test_custom_integration_version_not_valid( await loader.async_get_integration(hass, "test_no_version") assert ( - "The custom integration 'test_no_version' does not have a valid version key (None) in the manifest file and was blocked from loading." + "The custom integration 'test_no_version' does not have a version key in the manifest file and was blocked from loading." in caplog.text ) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 82ce10872bf..7e4dba42c2f 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -8,6 +8,7 @@ from homeassistant import loader, setup from homeassistant.requirements import ( CONSTRAINT_FILE, RequirementsNotFound, + async_clear_install_history, async_get_integration_with_requirements, async_process_requirements, ) @@ -89,7 +90,7 @@ async def test_install_missing_package(hass): ) as mock_inst, pytest.raises(RequirementsNotFound): await async_process_requirements(hass, "test_component", ["hello==1.0.0"]) - assert len(mock_inst.mock_calls) == 1 + assert len(mock_inst.mock_calls) == 3 async def test_get_integration_with_requirements(hass): @@ -188,9 +189,13 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha "test-comp==1.0.0", ] - assert len(mock_inst.mock_calls) == 3 + assert len(mock_inst.mock_calls) == 7 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp-dep==1.0.0", "test-comp-dep==1.0.0", "test-comp==1.0.0", ] @@ -208,6 +213,65 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" + assert len(mock_is_installed.mock_calls) == 1 + assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ + "test-comp==1.0.0", + ] + + # On another attempt we remember failures and don't try again + assert len(mock_inst.mock_calls) == 1 + assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ + "test-comp==1.0.0" + ] + + # Now clear the history and so we try again + async_clear_install_history(hass) + + with pytest.raises(RequirementsNotFound), patch( + "homeassistant.util.package.is_installed", return_value=False + ) as mock_is_installed, patch( + "homeassistant.util.package.install_package", side_effect=_mock_install_package + ) as mock_inst: + + integration = await async_get_integration_with_requirements( + hass, "test_component" + ) + assert integration + assert integration.domain == "test_component" + + assert len(mock_is_installed.mock_calls) == 3 + assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp==1.0.0", + ] + + assert len(mock_inst.mock_calls) == 7 + assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ + "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-after-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp-dep==1.0.0", + "test-comp==1.0.0", + ] + + # Now clear the history and mock success + async_clear_install_history(hass) + + with patch( + "homeassistant.util.package.is_installed", return_value=False + ) as mock_is_installed, patch( + "homeassistant.util.package.install_package", return_value=True + ) as mock_inst: + + integration = await async_get_integration_with_requirements( + hass, "test_component" + ) + assert integration + assert integration.domain == "test_component" + assert len(mock_is_installed.mock_calls) == 3 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ "test-comp-after-dep==1.0.0", diff --git a/tests/test_runner.py b/tests/test_runner.py index 7bbe96dd077..0e38cef0fff 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,8 +1,11 @@ """Test the runner.""" +import asyncio import threading from unittest.mock import patch +import pytest + from homeassistant import core, runner from homeassistant.util import executor, thread @@ -37,3 +40,80 @@ async def test_setup_and_run_hass(hass, tmpdir): assert threading._shutdown == thread.deadlock_safe_shutdown assert mock_run.called + + +def test_run(hass, tmpdir): + """Test we can run.""" + test_dir = tmpdir.mkdir("config") + default_config = runner.RuntimeConfig(test_dir) + + with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), patch( + "homeassistant.bootstrap.async_setup_hass", return_value=hass + ), patch("threading._shutdown"), patch( + "homeassistant.core.HomeAssistant.async_run" + ) as mock_run: + runner.run(default_config) + + assert mock_run.called + + +def test_run_executor_shutdown_throws(hass, tmpdir): + """Test we can run and we still shutdown if the executor shutdown throws.""" + test_dir = tmpdir.mkdir("config") + default_config = runner.RuntimeConfig(test_dir) + + with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), pytest.raises( + RuntimeError + ), patch("homeassistant.bootstrap.async_setup_hass", return_value=hass), patch( + "threading._shutdown" + ), patch( + "homeassistant.runner.InterruptibleThreadPoolExecutor.shutdown", + side_effect=RuntimeError, + ) as mock_shutdown, patch( + "homeassistant.core.HomeAssistant.async_run" + ) as mock_run: + runner.run(default_config) + + assert mock_shutdown.called + assert mock_run.called + + +def test_run_does_not_block_forever_with_shielded_task(hass, tmpdir, caplog): + """Test we can shutdown and not block forever.""" + test_dir = tmpdir.mkdir("config") + default_config = runner.RuntimeConfig(test_dir) + created_tasks = False + + async def _async_create_tasks(*_): + nonlocal created_tasks + + async def async_raise(*_): + try: + await asyncio.sleep(2) + except asyncio.CancelledError: + raise Exception + + async def async_shielded(*_): + try: + await asyncio.sleep(2) + except asyncio.CancelledError: + await asyncio.sleep(2) + + asyncio.ensure_future(asyncio.shield(async_shielded())) + asyncio.ensure_future(asyncio.sleep(2)) + asyncio.ensure_future(async_raise()) + await asyncio.sleep(0.1) + created_tasks = True + return 0 + + with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), patch( + "homeassistant.bootstrap.async_setup_hass", return_value=hass + ), patch("threading._shutdown"), patch( + "homeassistant.core.HomeAssistant.async_run", _async_create_tasks + ): + runner.run(default_config) + + assert created_tasks is True + assert ( + "Task could not be canceled and was still running after shutdown" in caplog.text + ) diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 58e4c6a2275..077ed292d10 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -9,6 +9,7 @@ from urllib.parse import parse_qs from aiohttp import ClientSession from aiohttp.client_exceptions import ClientError, ClientResponseError from aiohttp.streams import StreamReader +from multidict import CIMultiDict from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE @@ -179,7 +180,7 @@ class AiohttpClientMockResponse: self.response = response self.exc = exc self.side_effect = side_effect - self._headers = headers or {} + self._headers = CIMultiDict(headers or {}) self._cookies = {} if cookies: diff --git a/tests/util/test_ruamel_yaml.py b/tests/util/test_ruamel_yaml.py deleted file mode 100644 index b4e78a883af..00000000000 --- a/tests/util/test_ruamel_yaml.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Test Home Assistant ruamel.yaml loader.""" -import os -from tempfile import mkdtemp - -import pytest -from ruamel.yaml import YAML - -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.ruamel_yaml as util_yaml - -TEST_YAML_A = """\ -title: My Awesome Home -# Include external resources -resources: - - url: /local/my-custom-card.js - type: js - - url: /local/my-webfont.css - type: css - -# Exclude entities from "Unused entities" view -excluded_entities: - - weblink.router -views: - # View tab title. - - title: Example - # Optional unique id for direct access /lovelace/${id} - id: example - # Optional background (overwrites the global background). - background: radial-gradient(crimson, skyblue) - # Each view can have a different theme applied. - theme: dark-mode - # The cards to show on this view. - cards: - # The filter card will filter entities for their state - - type: entity-filter - entities: - - device_tracker.paulus - - device_tracker.anne_there - state_filter: - - 'home' - card: - type: glance - title: People that are home - - # The picture entity card will represent an entity with a picture - - type: picture-entity - image: https://www.home-assistant.io/images/default-social.png - entity: light.bed_light - - # Specify a tab icon if you want the view tab to be an icon. - - icon: mdi:home-assistant - # Title of the view. Will be used as the tooltip for tab icon - title: Second view - cards: - - id: test - type: entities - title: Test card - # Entities card will take a list of entities and show their state. - - type: entities - # Title of the entities card - title: Example - # The entities here will be shown in the same order as specified. - # Each entry is an entity ID or a map with extra options. - entities: - - light.kitchen - - switch.ac - - entity: light.living_room - # Override the name to use - name: LR Lights - - # The markdown card will render markdown text. - - type: markdown - title: Lovelace - content: > - Welcome to your **Lovelace UI**. -""" - -TEST_YAML_B = """\ -title: Home -views: - - title: Dashboard - id: dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack - cards: - - type: picture-entity - entity: group.sample - name: Sample - image: /local/images/sample.jpg - tap_action: toggle -""" - -# Test data that can not be loaded as YAML -TEST_BAD_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack -""" - -# Test unsupported YAML -TEST_UNSUP_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: !include cards.yaml -""" - -TMP_DIR = None - - -def setup(): - """Set up for tests.""" - global TMP_DIR - TMP_DIR = mkdtemp() - - -def teardown(): - """Clean up after tests.""" - for fname in os.listdir(TMP_DIR): - os.remove(os.path.join(TMP_DIR, fname)) - os.rmdir(TMP_DIR) - - -def _path_for(leaf_name): - return os.path.join(TMP_DIR, f"{leaf_name}.yaml") - - -def test_save_and_load(): - """Test saving and loading back.""" - yaml = YAML(typ="rt") - fname = _path_for("test1") - open(fname, "w+").close() - util_yaml.save_yaml(fname, yaml.load(TEST_YAML_A)) - data = util_yaml.load_yaml(fname, True) - assert data == yaml.load(TEST_YAML_A) - - -def test_overwrite_and_reload(): - """Test that we can overwrite an existing file and read back.""" - yaml = YAML(typ="rt") - fname = _path_for("test2") - open(fname, "w+").close() - util_yaml.save_yaml(fname, yaml.load(TEST_YAML_A)) - util_yaml.save_yaml(fname, yaml.load(TEST_YAML_B)) - data = util_yaml.load_yaml(fname, True) - assert data == yaml.load(TEST_YAML_B) - - -def test_load_bad_data(): - """Test error from trying to load unserialisable data.""" - fname = _path_for("test3") - with open(fname, "w") as fh: - fh.write(TEST_BAD_YAML) - with pytest.raises(HomeAssistantError): - util_yaml.load_yaml(fname, True)